Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/friendly-carrots-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'slate-react': minor
'slate-dom': minor
---

Fixes an editor crash that happens when editor is placed inside Shadow DOM and the user is typing on Android
2 changes: 2 additions & 0 deletions packages/slate-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {
} from './utils/diff-text'

export {
closestShadowAware,
containsShadowAware,
DOMElement,
DOMNode,
DOMPoint,
Expand Down
12 changes: 8 additions & 4 deletions packages/slate-dom/src/plugin/dom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from 'slate'
import { TextDiff } from '../utils/diff-text'
import {
closestShadowAware,
containsShadowAware,
DOMElement,
DOMNode,
DOMPoint,
Expand Down Expand Up @@ -499,12 +501,13 @@ export const DOMEditor: DOMEditorInterface = {
}

return (
targetEl.closest(`[data-slate-editor]`) === editorEl &&
closestShadowAware(targetEl, `[data-slate-editor]`) === editorEl &&
(!editable || targetEl.isContentEditable
? true
: (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined
// this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly)
targetEl.closest('[contenteditable="false"]') === editorEl) ||
closestShadowAware(targetEl, '[contenteditable="false"]') ===
editorEl) ||
!!targetEl.getAttribute('data-slate-zero-width'))
)
},
Expand Down Expand Up @@ -713,14 +716,15 @@ export const DOMEditor: DOMEditorInterface = {
// if this editor is within a void node of another editor ("nested editors", like in
// the "Editable Voids" example on the docs site).
const voidNode =
potentialVoidNode && editorEl.contains(potentialVoidNode)
potentialVoidNode && containsShadowAware(editorEl, potentialVoidNode)
? potentialVoidNode
: null
const potentialNonEditableNode = parentNode.closest(
'[contenteditable="false"]'
)
const nonEditableNode =
potentialNonEditableNode && editorEl.contains(potentialNonEditableNode)
potentialNonEditableNode &&
containsShadowAware(editorEl, potentialNonEditableNode)
? potentialNonEditableNode
: null
let leafNode = parentNode.closest('[data-slate-leaf]')
Expand Down
74 changes: 71 additions & 3 deletions packages/slate-dom/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,19 +299,19 @@ export const isTrackedMutation = (
}

const { document } = DOMEditor.getWindow(editor)
if (document.contains(target)) {
if (containsShadowAware(document, target)) {
return DOMEditor.hasDOMNode(editor, target, { editable: true })
}

const parentMutation = batch.find(({ addedNodes, removedNodes }) => {
for (const node of addedNodes) {
if (node === target || node.contains(target)) {
if (node === target || containsShadowAware(node, target)) {
return true
}
}

for (const node of removedNodes) {
if (node === target || node.contains(target)) {
if (node === target || containsShadowAware(node, target)) {
return true
}
}
Expand Down Expand Up @@ -355,3 +355,71 @@ export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean =>
node.compareDocumentPosition(otherNode) &
DOMNode.DOCUMENT_POSITION_FOLLOWING
)

/**
* Shadow DOM-aware version of Element.closest()
* Traverses up the DOM tree, crossing shadow DOM boundaries
*/
export const closestShadowAware = (
element: DOMElement | null | undefined,
selector: string
): DOMElement | null => {
if (!element) {
return null
}

let current: DOMElement | null = element

while (current) {
if (current.matches && current.matches(selector)) {
return current
}

if (current.parentElement) {
current = current.parentElement
} else if (current.parentNode && 'host' in current.parentNode) {
current = (current.parentNode as ShadowRoot).host as DOMElement
} else {
return null
}
}

return null
}

/**
* Shadow DOM-aware version of Node.contains()
* Checks if a node contains another node, crossing shadow DOM boundaries
*/
export const containsShadowAware = (
parent: DOMNode | null | undefined,
child: DOMNode | null | undefined
): boolean => {
if (!parent || !child) {
return false
}

if (parent.contains(child)) {
return true
}

let current: DOMNode | null = child

while (current) {
if (current === parent) {
return true
}

if (current.parentNode) {
if ('host' in current.parentNode) {
current = (current.parentNode as ShadowRoot).host
} else {
current = current.parentNode
}
} else {
return false
}
}

return false
}
5 changes: 3 additions & 2 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useTrackUserInput } from '../hooks/use-track-user-input'
import { ReactEditor } from '../plugin/react-editor'
import { TRIPLE_CLICK } from 'slate-dom'
import {
containsShadowAware,
DOMElement,
DOMRange,
DOMText,
Expand Down Expand Up @@ -404,8 +405,8 @@ export const Editable = forwardRef(
const editorElement = EDITOR_TO_ELEMENT.get(editor)!
let hasDomSelectionInEditor = false
if (
editorElement.contains(anchorNode) &&
editorElement.contains(focusNode)
containsShadowAware(editorElement, anchorNode) &&
containsShadowAware(editorElement, focusNode)
) {
hasDomSelectionInEditor = true
}
Expand Down
6 changes: 6 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ const projects = [
...devices['Desktop Firefox'],
},
},
{
name: 'mobile',
use: {
...devices['Pixel 5'],
},
},
]

if (os.type() === 'Darwin') {
Expand Down
33 changes: 32 additions & 1 deletion playwright/integration/examples/shadow-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,42 @@
await expect(textbox).toHaveCount(1)

// Clear any existing text and type new text into the textbox
await page.locator('[data-slate-editor]').selectText()
await textbox.click()
await page.keyboard.press('ControlOrMeta+A')

await page.keyboard.press('Backspace')
await page.keyboard.type('Hello, Playwright!')

// Assert that the textbox contains the correct text
await expect(textbox).toHaveText('Hello, Playwright!')

Check failure on line 34 in playwright/integration/examples/shadow-dom.test.ts

View workflow job for this annotation

GitHub Actions / test:integration

[mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content

3) [mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content Retry #3 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 8000ms waiting for expect(locator).toHaveText(expected) Locator: locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') Expected string: "Hello, Playwright!" Received string: "Helo, Playwright!" Call log: - expect.toHaveText with timeout 8000ms - waiting for locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') 12 × locator resolved to <div zindex="-1" role="textbox" translate="no" aria-multiline="true" contenteditable="true" data-slate-node="value" data-slate-editor="true">…</div> - unexpected value "Helo, Playwright!" 32 | 33 | // Assert that the textbox contains the correct text > 34 | await expect(textbox).toHaveText('Hello, Playwright!') | ^ 35 | }) 36 | 37 | test('user can type add a new line in editor inside shadow DOM', async ({ at /home/runner/work/slate/slate/playwright/integration/examples/shadow-dom.test.ts:34:27

Check failure on line 34 in playwright/integration/examples/shadow-dom.test.ts

View workflow job for this annotation

GitHub Actions / test:integration

[mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content

3) [mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 8000ms waiting for expect(locator).toHaveText(expected) Locator: locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') Expected string: "Hello, Playwright!" Received string: "Hello, lywright!" Call log: - expect.toHaveText with timeout 8000ms - waiting for locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') 12 × locator resolved to <div zindex="-1" role="textbox" translate="no" aria-multiline="true" contenteditable="true" data-slate-node="value" data-slate-editor="true">…</div> - unexpected value "Hello, lywright!" 32 | 33 | // Assert that the textbox contains the correct text > 34 | await expect(textbox).toHaveText('Hello, Playwright!') | ^ 35 | }) 36 | 37 | test('user can type add a new line in editor inside shadow DOM', async ({ at /home/runner/work/slate/slate/playwright/integration/examples/shadow-dom.test.ts:34:27

Check failure on line 34 in playwright/integration/examples/shadow-dom.test.ts

View workflow job for this annotation

GitHub Actions / test:integration

[mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content

3) [mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 8000ms waiting for expect(locator).toHaveText(expected) Locator: locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') Expected string: "Hello, Playwright!" Received string: "Hello, Playwrgt!" Call log: - expect.toHaveText with timeout 8000ms - waiting for locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') 12 × locator resolved to <div zindex="-1" role="textbox" translate="no" aria-multiline="true" contenteditable="true" data-slate-node="value" data-slate-editor="true">…</div> - unexpected value "Hello, Playwrgt!" 32 | 33 | // Assert that the textbox contains the correct text > 34 | await expect(textbox).toHaveText('Hello, Playwright!') | ^ 35 | }) 36 | 37 | test('user can type add a new line in editor inside shadow DOM', async ({ at /home/runner/work/slate/slate/playwright/integration/examples/shadow-dom.test.ts:34:27

Check failure on line 34 in playwright/integration/examples/shadow-dom.test.ts

View workflow job for this annotation

GitHub Actions / test:integration

[mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content

3) [mobile] › playwright/integration/examples/shadow-dom.test.ts:16:7 › shadow-dom example › renders slate editor inside nested shadow and edits content Error: Timed out 8000ms waiting for expect(locator).toHaveText(expected) Locator: locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') Expected string: "Hello, Playwright!" Received string: "Hllo, Plawiht!" Call log: - expect.toHaveText with timeout 8000ms - waiting for locator('[data-cy="outer-shadow-root"]').locator('> div').getByRole('textbox') 12 × locator resolved to <div zindex="-1" role="textbox" translate="no" aria-multiline="true" contenteditable="true" data-slate-node="value" data-slate-editor="true">…</div> - unexpected value "Hllo, Plawiht!" 32 | 33 | // Assert that the textbox contains the correct text > 34 | await expect(textbox).toHaveText('Hello, Playwright!') | ^ 35 | }) 36 | 37 | test('user can type add a new line in editor inside shadow DOM', async ({ at /home/runner/work/slate/slate/playwright/integration/examples/shadow-dom.test.ts:34:27
})

test('user can type add a new line in editor inside shadow DOM', async ({
page,
}) => {
const consoleErrors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text())
}
})

const pageErrors: Error[] = []
page.on('pageerror', error => {
pageErrors.push(error)
})

const outerShadow = page.locator('[data-cy="outer-shadow-root"]')
const innerShadow = outerShadow.locator('> div')
const textbox = innerShadow.getByRole('textbox')

await textbox.click()
await page.keyboard.press('Enter')
await page.keyboard.type('New line text')

expect(consoleErrors, 'Console errors occurred').toEqual([])
expect(pageErrors, 'Page errors occurred').toEqual([])

await expect(textbox).toContainText('New line text')
})
})
Loading