ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
27.55k stars 2.29k forks source link

[Bug]: Node Views rerenders in some strange way #5714

Closed piszczu4 closed 1 month ago

piszczu4 commented 1 month ago

Affected Packages

react

Version(s)

2.8.0

Bug Description

I created a custom Details extension like this:

import { DetailsNodeView } from '@/components/editor/extensions/extension-details/details-node-view';
import { Node, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react';

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    details: {
      setDetails: () => ReturnType;
      unsetDetails: () => ReturnType;
    };
  }
}

type DetailsOptions = {
  HTMLAttributes: Record<string, any>;
};

export const Details = Node.create<DetailsOptions>({
  name: 'details',
  content: 'detailsSummary detailsContent',
  group: 'block',
  defining: true,
  isolating: true,
  allowGapCursor: false,
  selectable: true,
  draggable: true,

  addOptions: () => ({
    HTMLAttributes: {},
  }),

  addAttributes() {
    return {
      open: {
        default: true,
        rendered: false,
      },
    };
  },

  parseHTML() {
    return [{ tag: `div[data-type="${this.name}"]` }];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
        'data-type': this.name,
      }),
      0,
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(DetailsNodeView, {
      attrs: {
        'data-copyable': 'true',
      },
    });
  },
});

import { DetailsContentNodeView } from '@/components/editor/extensions/extension-details/details-content-node-view';
import { Node, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react';

type DetailsContentOptions = {
  HTMLAttributes: Record<string, any>;
};

export const DetailsContent = Node.create<DetailsContentOptions>({
  name: 'detailsContent',
  content: 'block+',
  defining: true,
  selectable: false,

  addOptions: () => ({ HTMLAttributes: {} }),

  parseHTML() {
    return [{ tag: `div[data-type="${this.name}"]` }];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
        'data-type': this.name,
      }),
      0,
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(DetailsContentNodeView);
  },
});
import { DetailsSummaryNodeView } from '@/components/editor/extensions/extension-details/details-summary-node-view';
import { Node, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react';

type DetailsSummaryOptions = {
  HTMLAttributes: Record<string, any>;
};

export const DetailsSummary = Node.create<DetailsSummaryOptions>({
  name: 'detailsSummary',
  content: 'paragraph',
  defining: true,
  selectable: false,
  isolating: true,

  parseHTML() {
    return [
      {
        tag: `div[data-type="${this.name}"]`,
      },
    ];
  },

  renderHTML({ HTMLAttributes }) {
    return [
      'div',
      mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
        'data-type': this.name,
      }),
      0,
    ];
  },

  addNodeView() {
    return ReactNodeViewRenderer(DetailsSummaryNodeView);
  },
});

Each node has its own NodeView like below:

import { Details } from '@/components/rich-html/replacements/rich-details';
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';

export function DetailsNodeView({ node, HTMLAttributes }: NodeViewProps) {
  delete HTMLAttributes['data-copyable'];

  return (
    <NodeViewWrapper>
      <Details
        props={{
          ...HTMLAttributes,
          'data-type': node.type.name,
          'data-open': node.attrs.open,
        }}
      >
        <NodeViewContent />
      </Details>
    </NodeViewWrapper>
  );
}

import { findNode } from '@/components/editor/utils/utils';
import { DetailsSummary } from '@/components/rich-html/replacements/rich-details';
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';

export function DetailsSummaryNodeView({
  editor,
  node,
  HTMLAttributes,
  getPos,
}: NodeViewProps) {
  delete HTMLAttributes['data-copyable'];

  const detailsNode = findNode(editor, 'details');
  const open = detailsNode?.node.attrs.open;

  return (
    <NodeViewWrapper>
      <DetailsSummary
        open={open}
        setOpen={() => {
          editor
            .chain()
            .setTextSelection(getPos())
            .updateAttributes('details', { open: !open })
            .refreshNode({ node, pos: getPos() })
            .run();
        }}
      >
        <NodeViewContent />
      </DetailsSummary>
    </NodeViewWrapper>
  );
}
import { DetailsContent } from '@/components/rich-html/replacements/rich-details';
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';

export function DetailsContentNodeView({ HTMLAttributes }: NodeViewProps) {
  return (
    <NodeViewWrapper {...HTMLAttributes} data-type={'detailsContent'}>
      <DetailsContent>
        <NodeViewContent />
      </DetailsContent>
    </NodeViewWrapper>
  );
}

Here is how the components looks like:


import { RichOptions } from '@/components/rich-html/rich-html';
import { Button } from '@/components/ui/button';
import assert from 'assert';
import { DOMNode, Element, domToReact } from 'html-react-parser';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { GiMagnifyingGlass } from 'react-icons/gi';

export const RichDetails = ({ dom, options }: RichOptions) => {
  assert(dom.children.length === 2);

  const summary = dom.children[0] as Element;
  const content = dom.children[1] as Element;

  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <Details>
      <DetailsSummary open={isExpanded} setOpen={setIsExpanded}>
        {domToReact(summary.children as DOMNode[], options)}
      </DetailsSummary>
      {isExpanded && (
        <DetailsContent>
          {domToReact(content.children as DOMNode[], options)}
        </DetailsContent>
      )}
    </Details>
  );
};

export const Details = ({
  props,
  children,
}: {
  props?: Record<PropertyKey, string | boolean>;
  children: ReactNode;
}) => {
  return (
    <div
      className={
        'box-shadow-card filter-card !my-10 rounded-lg bg-card [&[data-open="false"]_[data-type="detailsContent"]]:hidden'
      }
      {...props}
    >
      {children}
    </div>
  );
};

export const DetailsSummary = ({
  open,
  setOpen,
  children,
}: {
  open: boolean;
  setOpen: (value: boolean) => void;
  children: ReactNode;
}) => {
  return (
    <div className='list-none p-4'>
      <div className='flex items-center gap-2'>
        <GiMagnifyingGlass className='mw-fill-gradient-gold h-7 w-7' />
        <h5 className='text-xl font-bold'>
          <div className='mw-gradient-gold caret-foreground'>{children}</div>
        </h5>
      </div>
      <DetailsTrigger open={open} setOpen={setOpen} />
    </div>
  );
};

export const DetailsTrigger = ({
  open,
  setOpen,
}: {
  open: boolean;
  setOpen: (value: boolean) => void;
}) => {
  return (
    <Button
      key={Math.random()}
      variant='outline'
      onClick={() => {
        setOpen(!open);
      }}
      contentEditable={false}
      className='mt-2 rounded-xl'
    >
      <span className='me-1'>{open ? <ChevronDown /> : <ChevronRight />}</span>
      {!open ? 'Pokaż' : 'Ukryj'}
    </Button>
  );
};

export const DetailsContent = ({ children }: { children: ReactNode }) => {
  return <div className={'border-t p-4'}>{children}</div>;
};

Everything works fine until I have 2 details nodes. If I click Open on one node, then the DetailsTrigger rerenders on all other nodes even though the NodeView of the other node does not rerender (i.e. deatilsNode in DetailsSumamryNodeView is the correct clicked node and only that node should rerender while the trigger for some reason rerenders in both ndoes. I tried to set some unique keys etc but nothing help. What might be the reason?

Browser Used

Chrome

Code Example URL

No response

Expected Behavior

Only one node view should rerender

Additional Context (Optional)

No response

Dependency Updates