Skip to content

Commit 33e74a8

Browse files
iperzicdylans
andauthored
Fix crashing editor when placed in ShadowDOM on Android (#5963)
* playwright test * properly select text * fix * revert retries * fix formatting * add changeset * cast proper types * remove redundant comments * Update playwright/integration/examples/shadow-dom.test.ts * make test cross OS compatible --------- Co-authored-by: Dylan Schiemann <[email protected]>
1 parent c6137cd commit 33e74a8

File tree

7 files changed

+128
-10
lines changed

7 files changed

+128
-10
lines changed

.changeset/friendly-carrots-fry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'slate-react': minor
3+
'slate-dom': minor
4+
---
5+
6+
Fixes an editor crash that happens when editor is placed inside Shadow DOM and the user is typing on Android

packages/slate-dom/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ export {
1818
} from './utils/diff-text'
1919

2020
export {
21+
closestShadowAware,
22+
containsShadowAware,
2123
DOMElement,
2224
DOMNode,
2325
DOMPoint,

packages/slate-dom/src/plugin/dom-editor.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from 'slate'
1212
import { TextDiff } from '../utils/diff-text'
1313
import {
14+
closestShadowAware,
15+
containsShadowAware,
1416
DOMElement,
1517
DOMNode,
1618
DOMPoint,
@@ -499,12 +501,13 @@ export const DOMEditor: DOMEditorInterface = {
499501
}
500502

501503
return (
502-
targetEl.closest(`[data-slate-editor]`) === editorEl &&
504+
closestShadowAware(targetEl, `[data-slate-editor]`) === editorEl &&
503505
(!editable || targetEl.isContentEditable
504506
? true
505507
: (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined
506508
// this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly)
507-
targetEl.closest('[contenteditable="false"]') === editorEl) ||
509+
closestShadowAware(targetEl, '[contenteditable="false"]') ===
510+
editorEl) ||
508511
!!targetEl.getAttribute('data-slate-zero-width'))
509512
)
510513
},
@@ -713,14 +716,15 @@ export const DOMEditor: DOMEditorInterface = {
713716
// if this editor is within a void node of another editor ("nested editors", like in
714717
// the "Editable Voids" example on the docs site).
715718
const voidNode =
716-
potentialVoidNode && editorEl.contains(potentialVoidNode)
719+
potentialVoidNode && containsShadowAware(editorEl, potentialVoidNode)
717720
? potentialVoidNode
718721
: null
719722
const potentialNonEditableNode = parentNode.closest(
720723
'[contenteditable="false"]'
721724
)
722725
const nonEditableNode =
723-
potentialNonEditableNode && editorEl.contains(potentialNonEditableNode)
726+
potentialNonEditableNode &&
727+
containsShadowAware(editorEl, potentialNonEditableNode)
724728
? potentialNonEditableNode
725729
: null
726730
let leafNode = parentNode.closest('[data-slate-leaf]')

packages/slate-dom/src/utils/dom.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,19 +299,19 @@ export const isTrackedMutation = (
299299
}
300300

301301
const { document } = DOMEditor.getWindow(editor)
302-
if (document.contains(target)) {
302+
if (containsShadowAware(document, target)) {
303303
return DOMEditor.hasDOMNode(editor, target, { editable: true })
304304
}
305305

306306
const parentMutation = batch.find(({ addedNodes, removedNodes }) => {
307307
for (const node of addedNodes) {
308-
if (node === target || node.contains(target)) {
308+
if (node === target || containsShadowAware(node, target)) {
309309
return true
310310
}
311311
}
312312

313313
for (const node of removedNodes) {
314-
if (node === target || node.contains(target)) {
314+
if (node === target || containsShadowAware(node, target)) {
315315
return true
316316
}
317317
}
@@ -355,3 +355,71 @@ export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean =>
355355
node.compareDocumentPosition(otherNode) &
356356
DOMNode.DOCUMENT_POSITION_FOLLOWING
357357
)
358+
359+
/**
360+
* Shadow DOM-aware version of Element.closest()
361+
* Traverses up the DOM tree, crossing shadow DOM boundaries
362+
*/
363+
export const closestShadowAware = (
364+
element: DOMElement | null | undefined,
365+
selector: string
366+
): DOMElement | null => {
367+
if (!element) {
368+
return null
369+
}
370+
371+
let current: DOMElement | null = element
372+
373+
while (current) {
374+
if (current.matches && current.matches(selector)) {
375+
return current
376+
}
377+
378+
if (current.parentElement) {
379+
current = current.parentElement
380+
} else if (current.parentNode && 'host' in current.parentNode) {
381+
current = (current.parentNode as ShadowRoot).host as DOMElement
382+
} else {
383+
return null
384+
}
385+
}
386+
387+
return null
388+
}
389+
390+
/**
391+
* Shadow DOM-aware version of Node.contains()
392+
* Checks if a node contains another node, crossing shadow DOM boundaries
393+
*/
394+
export const containsShadowAware = (
395+
parent: DOMNode | null | undefined,
396+
child: DOMNode | null | undefined
397+
): boolean => {
398+
if (!parent || !child) {
399+
return false
400+
}
401+
402+
if (parent.contains(child)) {
403+
return true
404+
}
405+
406+
let current: DOMNode | null = child
407+
408+
while (current) {
409+
if (current === parent) {
410+
return true
411+
}
412+
413+
if (current.parentNode) {
414+
if ('host' in current.parentNode) {
415+
current = (current.parentNode as ShadowRoot).host
416+
} else {
417+
current = current.parentNode
418+
}
419+
} else {
420+
return false
421+
}
422+
}
423+
424+
return false
425+
}

packages/slate-react/src/components/editable.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useTrackUserInput } from '../hooks/use-track-user-input'
3535
import { ReactEditor } from '../plugin/react-editor'
3636
import { TRIPLE_CLICK } from 'slate-dom'
3737
import {
38+
containsShadowAware,
3839
DOMElement,
3940
DOMRange,
4041
DOMText,
@@ -404,8 +405,8 @@ export const Editable = forwardRef(
404405
const editorElement = EDITOR_TO_ELEMENT.get(editor)!
405406
let hasDomSelectionInEditor = false
406407
if (
407-
editorElement.contains(anchorNode) &&
408-
editorElement.contains(focusNode)
408+
containsShadowAware(editorElement, anchorNode) &&
409+
containsShadowAware(editorElement, focusNode)
409410
) {
410411
hasDomSelectionInEditor = true
411412
}

playwright.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ const projects = [
2323
...devices['Desktop Firefox'],
2424
},
2525
},
26+
{
27+
name: 'mobile',
28+
use: {
29+
...devices['Pixel 5'],
30+
},
31+
},
2632
]
2733

2834
if (os.type() === 'Darwin') {

playwright/integration/examples/shadow-dom.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,42 @@ test.describe('shadow-dom example', () => {
2424
await expect(textbox).toHaveCount(1)
2525

2626
// Clear any existing text and type new text into the textbox
27-
await page.locator('[data-slate-editor]').selectText()
27+
await textbox.click()
28+
await page.keyboard.press('ControlOrMeta+A')
29+
2830
await page.keyboard.press('Backspace')
2931
await page.keyboard.type('Hello, Playwright!')
3032

3133
// Assert that the textbox contains the correct text
3234
await expect(textbox).toHaveText('Hello, Playwright!')
3335
})
36+
37+
test('user can type add a new line in editor inside shadow DOM', async ({
38+
page,
39+
}) => {
40+
const consoleErrors: string[] = []
41+
page.on('console', msg => {
42+
if (msg.type() === 'error') {
43+
consoleErrors.push(msg.text())
44+
}
45+
})
46+
47+
const pageErrors: Error[] = []
48+
page.on('pageerror', error => {
49+
pageErrors.push(error)
50+
})
51+
52+
const outerShadow = page.locator('[data-cy="outer-shadow-root"]')
53+
const innerShadow = outerShadow.locator('> div')
54+
const textbox = innerShadow.getByRole('textbox')
55+
56+
await textbox.click()
57+
await page.keyboard.press('Enter')
58+
await page.keyboard.type('New line text')
59+
60+
expect(consoleErrors, 'Console errors occurred').toEqual([])
61+
expect(pageErrors, 'Page errors occurred').toEqual([])
62+
63+
await expect(textbox).toContainText('New line text')
64+
})
3465
})

0 commit comments

Comments
 (0)