`user.type()` doesn't handle clearing of textareas in shadow DOM · Issue #1314 · testing-library/user-event · GitHub
Skip to content

user.type() doesn't handle clearing of textareas in shadow DOM #1314

Open

Description

@mortenpi

Reproduction example

https://codesandbox.io/p/devbox/still-leaf-dfv3dd?file=%2Fmwe.ts&workspaceId=ws_WjQcfkhNBektSLNjvEXy89

Prerequisites

Do user.type() on e.g. a textarea in shadow DOM / web component, clear it with e.g. .value = "" or form.reset(), and user.type() some more.

Expected behavior

The testing library should handle the clearing correctly. It works as expected in light DOM.

Actual behavior

When you user.type() the second time, the content from the first user.type() get put into the textarea.

User-event version

14.6.1

Environment

@testing-library/user-event: 14.6.1, Node v24.15.0, Linux

Additional context

This MWE should make it explicit. It seems to be a problem with how the caching is handled? Note that the unexpected behavior is the most clear on the second to last line.

// @vitest-environment jsdom
/* package.json:
{
  "name": "user-event-shadow-dom-repro",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run"
  },
  "devDependencies": {
    "@testing-library/user-event": "^14.6.1",
    "jsdom": "^30.0.1",
    "vitest": "^4.1.10"
  }
}
*/
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";

function getCachedUIValue(ta: HTMLTextAreaElement) {
  for (const sym of Object.getOwnPropertySymbols(ta)) {
    if (sym.description === "Displayed value in UI") {
      return (ta as unknown as Record<symbol, unknown>)[sym] ?? "";
    }
  }
}

function lightTextarea(): HTMLTextAreaElement {
  const ta = document.createElement("textarea");
  document.body.append(ta);
  return ta;
}
test("light DOM works as expected", async () => {
  const user = userEvent.setup();
  const ta = lightTextarea();

  // Type something into the <textarea>
  await user.type(ta, "hello");
  expect(ta.value).toBe("hello");
  expect(getCachedUIValue(ta)).toBe("hello");

  // Clear the textarea content. FWIW, form.reset() has the same effect.
  ta.value = "";
  expect(ta.value).toBe("");
  expect(getCachedUIValue(ta)).toBe("");

  // Type some more text. In principle, the <textarea> should now only contain "world"
  await user.type(ta, "world");
  expect(ta.value).toBe("world");
  expect(getCachedUIValue(ta)).toBe("world");
});

// MWE
function shadowTextarea(): { host: HTMLElement; ta: HTMLTextAreaElement } {
  const host = document.createElement("div");
  const shadow = host.attachShadow({ mode: "open" });
  const ta = document.createElement("textarea");
  shadow.append(ta);
  document.body.append(host);
  return { host, ta };
}
test.fails("but shadow DOM doesn't", async () => {
  const user = userEvent.setup();
  const { ta } = shadowTextarea();

  // Type something into the <textarea>
  await user.type(ta, "hello");
  expect(ta.value).toBe("hello");
  expect(getCachedUIValue(ta)).toBe("hello");

  // Clear the textarea content. FWIW, form.reset() has the same effect.
  ta.value = "";
  expect(ta.value).toBe("");
  expect(getCachedUIValue(ta)).toBe("");

  // Type some more text. In principle, the <textarea> should now only contain "world"
  await user.type(ta, "world");
  expect(ta.value).toBe("world");
  expect(getCachedUIValue(ta)).toBe("world");
});

test("instead, shadow DOM has an outdated cache", async () => {
  const user = userEvent.setup();
  const { ta } = shadowTextarea();

  // Type something into the <textarea>
  await user.type(ta, "hello");
  expect(ta.value).toBe("hello");
  expect(getCachedUIValue(ta)).toBe("hello");

  // Clear the textarea content. FWIW, form.reset() has the same effect.
  ta.value = "";
  expect(ta.value).toBe("");
  expect(getCachedUIValue(ta)).toBe("hello");

  // Type some more text. In principle, the <textarea> should now only contain "world"
  await user.type(ta, "world");

  // !!! This is the main MWE / unexpected behavior
  expect(ta.value).toBe("helloworld");
  // !!!

  expect(getCachedUIValue(ta)).toBe("helloworld");
});

Activity

  1. added
    bugSomething isn't working
    needs assessmentThis needs to be looked at by a team member
    on Jul 30, 2026
  2. suhailopensource commented on Jul 30, 2026

    @suhailopensource

    @mortenpi i Dug into this - its a focus retargeting issue, not really caching.

    The value-setter interceptor (which keeps the tracked UI value in sync on programmatic .value = assignments) is only installed by prepareElement, which runs solely from the document-level focus capture listener in prepareDocument.ts. For an element in a shadow root, focus is retargeted to the shadow host, so e.target is the <div>, not the inner <textarea> — the interceptor never gets installed on the textarea.

    The "hello" cache is written directly during typing, but ta.value = "" has no interceptor to run trackOrSetValue → setUIValueClean, so it stays stale and the next type() appends → "helloworld".

    Likely fix: resolve the real focused element via e.composedPath()[0] before calling prepareElement (closed shadow roots would remain a known limitation).

    also like since theres no shadow DOM handling in src today — is a fix for this in scope?

    I have been looking into the codebase for the past 2 days so i might be wrong too . lets wait for the maintainers to review my fix and verify it before i send a PR .

    cc: @ph-fritsche

  3. added a commit that references this issue on Sep 12, 2026
    5861761
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds assessmentThis needs to be looked at by a team member

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions