facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
19.84k stars 1.68k forks source link

Bug: Keyboard events not registering in content editable with react testing library #4595

Open aidenywl opened 1 year ago

aidenywl commented 1 year ago

Detailed description is from @doytch here that I'm pasting in this issue.

I've created a new component - let's call it RichTextEditor - that renders a LexicalComposer along with a custom EmailAddressPlugin. Said plugin has a decorator EmailAddressNode which tokenizes the email strings like so:

Screen Shot 2022-07-19 at 4 55 22 PM

Nothing super fancy, think the tokenizing in most email clients' composer's "To" fields.

I'd like to be able to write some tests to verify the functionality from the user's perspective. Eg, select the textarea, press keyboard keys, verify the DOM.

I'm using Remix + Vitest + RTL and quickly ran into issues where it seems like something's not being picked up properly.

  test("should trigger `onChange` when it is typed into", async () => {
    const spy = vi.fn((nodes) => console.log(nodes));
    const { user } = render(<RichTextEditor onChange={spy} />);

    const textbox = screen.getAllByRole("textbox")[0];

    await user.click(textbox);
    await user.keyboard("foo");

    screen.debug(); // this doesn't include "foo"

    await screen.findByText("foo"); // this fails
  });

The output of screen.debug() (an RTL helper) included the following:

    <div
      class="h-40 rounded-lg border-border-light p-4 text-body2 leading-[30px] shadow-xs outline-none transition focus:shadow-focus \""
      contenteditable="true"
      data-lexical-editor="true"
      id="rich-text-editor-ce"
      role="textbox"
      spellcheck="true"
      style="user-select: text; white-space: pre-wrap; word-break: break-word;"
    >
      <p>
        <br />
      </p>
    </div>
    <div
      class="pointer-events-none relative -top-[154px] left-4 text-body2 text-text-secondary"
      data-test-id="rich-text-editor-placeholder"
    >
      Enter some text...
    </div>

That output (the p and br especially) suggests to me that the contenteditable is being focused but the keyboard events aren't being registered properly. If I snoop a bit using the OnChangePlugin, then I can see the onChange is firing four times but there isn't anything in the SerializedEditorState besides that p and br either.

Question 1: Before I keep digging more into this, is this a known issue with jsdom or other headless engines (happy-dom?)? I noticed that the project uses Cypress and I'm wondering whether that's because the tests I want to write are simply not possible with headless unit tests like I'm using.

Question 2: If I am up a creek writing these kinds of unit tests, what's the suggested way of testing custom nodes and plugins? I know there are a bunch of unit tests in the codebase for the builtin nodes/plugins but given the 0.x.y nature of the project I'm wary about drawing toooo many conclusions (especially after I went down an inert rabbit hole before reading a PR deprecating it 😅). Are there any exemplars I could pattern my tests off of? If it doesn't exist yet, I'd love to take this as an opportunity to build a "So you want to test your custom node/plugin?" doc.

Originally posted by @doytch in https://github.com/facebook/lexical/discussions/2659

Lexical version: 0.11.1, but seems to happen for all previous versions as well. This isn't a recent regression.

Steps To Reproduce

  1. Create a RichTextEditor component
  2. Render it in RTL
  3. Attempt to fireEvent or do a userEvent.keyboard or userEvent.type
  4. Try to assert that the typed word is present, but the test fails.

Another example:

  test.only('RichTextEditor correctly registers typing.', async () => {
    render(<RichTextEditor onChange={jest.fn()} />);
    await userEvent.keyboard('foo');
    screen.logTestingPlaygroundURL();
    await screen.findByText('foo');
  });

The current behavior

The test fails, 'foo' is not found

The expected behavior

'foo' is found in playwright

aidenywl commented 1 year ago

Looked briefly into the source code for 0.11.1, this might be a case of the beforeinput event somehow not firing in RTL. Codepath: https://github.com/facebook/lexical/blob/c9ab0d10578f610f161a244026d7d1d4194624f2/packages/lexical/src/LexicalEvents.ts#L157

There seems to be a semi-related issue on this here: https://github.com/testing-library/user-event/issues/858

We're currently on an older RTL version ~v12+. I'm not sure whether this is a bug with the library or whether simply upgrading RTL will fix it. If anyone has more information on this please add on!

alvarogfn commented 1 year ago

While I was trying to figure out some way around this problem, I noticed that the react testing library's keyboard was able to write to the contentEditable in this particular case.

it('should type in content editable', async () => {
  const App = () => {
    const [editor] = useLexicalComposerContext()
    useEffect(() => {
      editor.update(() => {
        const root = $getRoot()
        const text = $createTextNode('existing text')
        const paragraph = $createParagraphNode()
        paragraph.append(text)
        root.getFirstChild()?.replace(paragraph)
      })
    }, [editor])
    return null
  }

  const component = (
    <LexicalComposer initialConfig={{ namespace: 'test', onError: console.error }}>
      <PlainTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>placeholder</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <App />
    </LexicalComposer>
  )

  render(component)

  const container = screen.getByRole('textbox')

  screen.debug(container)

  await user.click(container)
  await user.keyboard('test')

  screen.debug(container)
})

Captura de tela de 2023-06-02 16-00-54

It seems that when useEffect called an editor.update(), the text typed by the keyboard appeared.

aiden-low commented 1 year ago

hmm @alvarogfn thanks for the code samples! that seems to work though it's because the text is being manually populated on useEffect by root.getFirstChild()?.replace(paragraph) (correct me if I'm wrong). It would not work for other nodes that do not have the same replace behaviour.

moy2010 commented 1 year ago

An FYI is that editors that rely on contenteditable cannot be tested with RTL due to limitations stemming from js-dom.

The usual workaround is to use a browser-automation testing tool such as Cypress or Playwright.

alvarogfn commented 1 year ago

@aiden-low In fact, the editable content just needs to have some content inside it other than the

<div
  contenteditable="true"
  data-lexical-editor="true"
  role="textbox"
  spellcheck="true"
  style="user-select: text; white-space: pre-wrap; word-break: break-word;"
>
  <p>
    <br />
  </p>
</div>

and then you can "use the type and keyboard events inside the lexical editor to type".

I don't know exactly why of this behavior.

Steps to Reproduce:

If you initialize the editable content with some content, with a space character for example:

function initialState() {
  const root = $getRoot()
  const paragraph = $createParagraphNode()
  paragraph.append($createTextNode(' '))
  root.append(paragraph)
}

const App = () => (
  <LexicalComposer initialConfig={{ namespace: 'editor', onError: console.error, editorState: initialState }}>
    <PlainTextPlugin
      contentEditable={<ContentEditable />}
      placeholder={<div>placeholder</div>}
      ErrorBoundary={LexicalErrorBoundary}
    />
  </LexicalComposer>
)

render(<App />)

const textbox = await screen.findByRole('textbox')

screen.debug(textbox)

screen debug output: Captura de tela de 2023-06-06 11-00-41

And using keyboard events with RTL, the content will be typed in that same span.

const textbox = await screen.findByRole('textbox')
await user.type(textbox, 'typing...')
screen.debug(textbox)

screen.debug output (the space is still there): Captura de tela de 2023-06-06 11-03-00

One difference is that {enter} events will wrap on the same span without a br, in contrast to the lexical in the real editor.

await user.type(textbox, '{enter}{enter}')

browser: Captura de tela de 2023-06-06 11-10-36

screen.debug output: Captura de tela de 2023-06-06 11-15-41

The version of packages are:

"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^14.2.1",
"jest": "^28.1.1",
"lexical": "^0.10.0",
"react": "^18.2.0",

I hope this helps in some way.

vedkribhu commented 1 year ago

I can confirm that with some initial content inside editor, the userevent.type / userevent.keyboard is working.

(edit) Found a workaround, seems like userEvent.type does not trigger input event for contenteditable elements.

const urlInput = await screen.findByRole('textbox')
userEvent.type(urlInput, 'hii')
fireEvent.input(urlInput, { data: 'hii' });
GermanJablo commented 1 year ago

From my testing, using userEvent in Lexical sometimes modifies the editor and sometimes doesn't. But when it does modify it, it does not do so based on Lexical commands or transformations.

For example, considering this initial state:

<!-- The 'I' represents the cursor -->
<p>aIa</p>
<p>bb</p>

If I run userEvent.keyboard('{enter}'); twice I get:

<!-- in jsdom -->
<p>a

a</p>
<p>bb</p>

<!-- in chromium-->
<p>a</p>
<p></p>
<p>a</p>
<p>bb</p>

If I instead run userEvent.keyboard('{delete}') twice:

<p>a</p>
<p>bb</p>

It seems that delete doesn't work when it's at the end of a paragraph.

An FYI is that editors that rely on contenteditable cannot be tested with RTL due to limitations stemming from js-dom.

Yes, that's what it seems. But testing in real browsers is too slow. There has to be some way to fix jsdom. I think having userEvent somehow trigger Lexical commands would be enough. Right now it fires the listeners, but not the commands.

GermanJablo commented 8 months ago

I have written an article on how to write unit or integration tests in the browser in case anyone is interested: https://www.germanjablo.com/p/browser-testing

I think this thread should be closed, as it is a jsdom issue, not a lexical one.