You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
user.type() doesn't handle clearing of textareas in shadow DOM #1314
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" }}*/importuserEventfrom"@testing-library/user-event";import{expect,test}from"vitest";functiongetCachedUIValue(ta: HTMLTextAreaElement){for(constsymofObject.getOwnPropertySymbols(ta)){if(sym.description==="Displayed value in UI"){return(taasunknownasRecord<symbol,unknown>)[sym]??"";}}}functionlightTextarea(): HTMLTextAreaElement{constta=document.createElement("textarea");document.body.append(ta);returnta;}test("light DOM works as expected",async()=>{constuser=userEvent.setup();constta=lightTextarea();// Type something into the <textarea>awaituser.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"awaituser.type(ta,"world");expect(ta.value).toBe("world");expect(getCachedUIValue(ta)).toBe("world");});// MWEfunctionshadowTextarea(): {host: HTMLElement;ta: HTMLTextAreaElement}{consthost=document.createElement("div");constshadow=host.attachShadow({mode: "open"});constta=document.createElement("textarea");shadow.append(ta);document.body.append(host);return{ host, ta };}test.fails("but shadow DOM doesn't",async()=>{constuser=userEvent.setup();const{ ta }=shadowTextarea();// Type something into the <textarea>awaituser.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"awaituser.type(ta,"world");expect(ta.value).toBe("world");expect(getCachedUIValue(ta)).toBe("world");});test("instead, shadow DOM has an outdated cache",async()=>{constuser=userEvent.setup();const{ ta }=shadowTextarea();// Type something into the <textarea>awaituser.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"awaituser.type(ta,"world");// !!! This is the main MWE / unexpected behaviorexpect(ta.value).toBe("helloworld");// !!!expect(getCachedUIValue(ta)).toBe("helloworld");});
@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 .
Activity
suhailopensource commentedon Jul 30, 2026
@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
fix: track value of inputs and textareas in shadow DOM