Skip to content

Commit 8a1f7cd

Browse files
authored
Fix web interop container rendering and viewport hierarchy issues (JetBrains#2710)
- Prevent `insertBefore` call if interop view position is not changed. - Update viewport composition to clear existing container elements. - Adjust HTML hierarchy for proper interop container location outside the shadow DOM. Fixes https://youtrack.jetbrains.com/issue/CMP-9667 ## Release Notes ### Fixes - Web - Adjust HTML hierarchy for proper interop container location outside the shadow DOM.
1 parent d5ba164 commit 8a1f7cd

8 files changed

Lines changed: 91 additions & 59 deletions

File tree

compose/mpp/demo/src/webMain/kotlin/androidx/compose/mpp/demo/Utils.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package androidx.compose.mpp.demo
1818

1919
import androidx.compose.ui.platform.ClipEntry
2020
import kotlinx.browser.document
21+
import org.w3c.dom.HTMLDivElement
2122

2223
expect suspend fun ClipEntry?.getPlainText(): String?
2324

@@ -52,7 +53,8 @@ internal fun setupBackingTextAreaDebugHints() {
5253
}
5354
""".trimIndent()
5455

55-
val shadowRoot = document.getElementById("composeApplication")?.shadowRoot!!
56+
val container = document.getElementById("composeApplication") as HTMLDivElement
57+
val shadowRoot = (container.firstChild?.firstChild as HTMLDivElement).shadowRoot!!
5658

5759
shadowRoot.prepend(shadowRootStyle)
5860
shadowRoot.appendChild(document.createElement("div").apply {

compose/ui/ui/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,9 @@ if(AndroidXComposePlugin.isMultiplatformEnabled(project)) {
333333
nativeTest {
334334
dependsOn(skikoTest)
335335
}
336-
webTest {}
336+
webTest {
337+
dependsOn(skikoTest)
338+
}
337339
jsTest {
338340
dependsOn(webTest)
339341
dependsOn(skikoTest)

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/viewinterop/WebInteropElementHolder.web.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ internal abstract class WebInteropElementHolder<T : HTMLElement>(
116116

117117
override fun changeInteropViewIndex(root: InteropViewGroup, index: Int) {
118118
val referenceNode = root.htmlElement.children.item(index)
119+
if (referenceNode === group.htmlElement) return
119120

120121
root.htmlElement.insertBefore(group.htmlElement, referenceNode)
121122
}

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package androidx.compose.ui.window
1919
import androidx.compose.runtime.Composable
2020
import androidx.compose.ui.ExperimentalComposeUiApi
2121
import kotlinx.browser.document
22+
import kotlinx.dom.clear
2223
import org.w3c.dom.Element
2324
import org.w3c.dom.HTMLCanvasElement
2425
import org.w3c.dom.HTMLDivElement
@@ -62,35 +63,47 @@ fun ComposeViewport(
6263
* Creates the composition in HTML canvas created in parent container identified by [viewportContainer] Element.
6364
* This size of canvas is adjusted with the size of the container
6465
*
65-
* The current hierarchy:
66-
* <viewportContainer.shadowDom>
67-
* <app root>
66+
* <container>
67+
* <positioning_container>
68+
* <shadow_container.shadow>
69+
* <style/>
70+
* <app_container>
6871
* <canvas/>
69-
* <interop elements container/>
70-
* <a11y elements root/>
71-
* </app root>
72-
* </viewportContainer.shadowDom>
72+
* <a11y_container/>
73+
* </app_container>
74+
* </shadow_container>
75+
* <interopContainer/>
76+
* <positioning_container/>
77+
* </container>
78+
*
79+
* Note: The viewportContainer will be cleared on composition creation.
7380
*/
7481
@ExperimentalComposeUiApi
7582
fun ComposeViewport(
7683
viewportContainer: Element,
7784
configure: ComposeViewportConfiguration.() -> Unit = {},
7885
content: @Composable () -> Unit = { }
7986
) = onSkikoReady {
80-
val canvas = document.createElement("canvas") as HTMLCanvasElement
81-
canvas.setAttribute("tabindex", "0")
82-
canvas.setAttribute("role", "generic")
83-
canvas.style.outline = "none" // Fixes https://youtrack.jetbrains.com/issue/CMP-9040
87+
viewportContainer.clear()
8488

85-
// Create a common container (parent html element) for canvas and the interop container
86-
// to position at the same place - the interop container is position at 0,0 relative to <canvas>.
89+
// Create a common positioning container (parent html element) for shadow and the interop containers
90+
// to position at the same place - the interop container is position at 0,0 relative to the shadow.
8791
// It simplifies the positioning of the interop views in the container.
88-
val layerRoot = document.createElement("div") as HTMLElement
89-
layerRoot.style.apply {
92+
val positioningContainer = document.createElement("div") as HTMLDivElement
93+
positioningContainer.style.apply {
94+
position = "relative"
95+
}
96+
viewportContainer.appendChild(positioningContainer)
97+
98+
//shadow container
99+
val shadowContainer = document.createElement("div") as HTMLDivElement
100+
shadowContainer.style.apply {
90101
position = "relative"
91102
}
103+
positioningContainer.appendChild(shadowContainer)
92104

93-
val shadowRoot = viewportContainer.attachShadow(ShadowRootInit(ShadowRootMode.OPEN))
105+
//shadow
106+
val shadowRoot = shadowContainer.attachShadow(ShadowRootInit(ShadowRootMode.OPEN))
94107
val shadowRootStyle = document.createElement("style")
95108
shadowRootStyle.textContent = """
96109
:host {
@@ -101,38 +114,50 @@ fun ComposeViewport(
101114
position: relative;
102115
}
103116
""".trimIndent()
117+
shadowRoot.appendChild(shadowRootStyle)
104118

105-
shadowRoot.append(shadowRootStyle, layerRoot)
106-
layerRoot.appendChild(canvas)
107-
108-
val interopContainerElement = document.createElement("div") as HTMLDivElement
109-
layerRoot.appendChild(interopContainerElement)
110-
111-
interopContainerElement.style.apply {
112-
position = "absolute"
113-
top = "0"
114-
left = "0"
119+
//app container (canvas + a11y)
120+
val appContainer = document.createElement("div") as HTMLElement
121+
appContainer.style.apply {
122+
position = "relative"
115123
}
124+
shadowRoot.appendChild(appContainer)
116125

117-
val configuration = ComposeViewportConfiguration().apply(configure)
126+
//canvas
127+
val canvas = document.createElement("canvas") as HTMLCanvasElement
128+
canvas.setAttribute("tabindex", "0")
129+
canvas.setAttribute("role", "generic")
130+
canvas.style.outline = "none" // Fixes https://youtrack.jetbrains.com/issue/CMP-9040
131+
appContainer.appendChild(canvas)
118132

133+
//a11y container
134+
val configuration = ComposeViewportConfiguration().apply(configure)
119135
val a11yContainerElement = if (configuration.isA11YEnabled) {
120136
(document.createElement("div") as HTMLDivElement).also { a11yContainer ->
121-
layerRoot.appendChild(a11yContainer)
122137
a11yContainer.style.apply {
123138
position = "absolute"
124139
top = "0"
125140
left = "0"
126141
}
142+
appContainer.appendChild(a11yContainer)
127143
}
128144
} else {
129145
null
130146
}
131147

148+
//interop container
149+
val interopContainerElement = document.createElement("div") as HTMLDivElement
150+
interopContainerElement.style.apply {
151+
position = "absolute"
152+
top = "0"
153+
left = "0"
154+
}
155+
positioningContainer.appendChild(interopContainerElement)
156+
132157
ComposeWindow(
133158
canvas = canvas,
134159
rootNode = shadowRoot,
135-
layerRoot = layerRoot,
160+
layerRoot = appContainer,
136161
interopContainerElement = interopContainerElement,
137162
a11yContainerElement = a11yContainerElement,
138163
content = content,

compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ internal class ComposeWindow(
276276
get() = activeTouchOffset
277277

278278
override val backingDomInputContainer: HTMLElement
279-
get() = interopContainerElement
279+
get() = layerRoot
280280

281281
override fun getNewGeometryForBackingInput(rect: Rect): DpRect {
282282
val dpRect = rect.toDpRect(density)

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/OnCanvasTests.kt

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import kotlinx.coroutines.test.runTest
4343
import kotlinx.coroutines.yield
4444
import org.w3c.dom.Element
4545
import org.w3c.dom.HTMLCanvasElement
46+
import org.w3c.dom.HTMLDivElement
4647
import org.w3c.dom.HTMLElement
4748
import org.w3c.dom.ShadowRoot
4849
import org.w3c.dom.events.Event
@@ -76,26 +77,26 @@ internal interface OnCanvasTests {
7677
(getContainer() as CanReplaceChildren).replaceChildren()
7778
}
7879

80+
/*
81+
<container>
82+
<positioning_container>
83+
<shadow_container.shadow>
84+
<style/>
85+
<app_container>
86+
<canvas/>
87+
<a11y_container/>
88+
</app_container>
89+
</shadow_container>
90+
<interopContainer/>
91+
<positioning_container/>
92+
</container>
93+
*/
7994
private fun getContainer() = document.getElementById(containerId) ?: error("failed to get canvas with id ${containerId}")
80-
81-
private fun getAppRoot() = getShadowRoot().children[1] as HTMLElement
82-
83-
fun getA11YContainer(): HTMLElement? {
84-
return if (getAppRoot().children.length < 3) {
85-
null
86-
} else {
87-
// The expected order is: canvas, interop container <div>, a11y container <div>
88-
getAppRoot().children[2] as HTMLElement
89-
}
90-
}
91-
92-
fun getShadowRoot(): ExtendedShadowRoot =
93-
(getContainer().shadowRoot as? ExtendedShadowRoot) ?: error("failed to get shadowRoot")
94-
95-
fun getCanvas(): HTMLCanvasElement {
96-
val canvas = (getShadowRoot().querySelector("canvas") as? HTMLCanvasElement) ?: error("failed to get canvas")
97-
return canvas
98-
}
95+
private fun getPositioningContainer() = getContainer().children[0] ?: error("failed to get positioning container")
96+
fun getShadowRoot() = (getPositioningContainer().children[0]?.shadowRoot as? ExtendedShadowRoot) ?: error("failed to get shadowRoot")
97+
private fun getAppRoot() = getShadowRoot().children[1] as? HTMLElement ?: error("failed to get app root")
98+
fun getCanvas() = getAppRoot().children[0] as? HTMLCanvasElement ?: error("failed to get canvas")
99+
fun getA11YContainer() = getAppRoot().children[1] as? HTMLDivElement
99100

100101
suspend fun createComposeWindow(
101102
configure: ComposeViewportConfiguration.() -> Unit = {},

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/CfWA11YTest.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,9 +424,8 @@ class CfWA11YTest : OnCanvasTests {
424424
val appContainer = getCanvas().parentElement as HTMLElement
425425

426426
assertTrue(appContainer.isConnected)
427-
assertEquals(2, appContainer.children.length)
427+
assertEquals(1, appContainer.children.length)
428428
assertEquals(getCanvas(), appContainer.children[0])
429-
assertEquals("DIV", appContainer.children[1]!!.tagName) // interop container
430429
}
431430

432431
@Test

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/viewinterop/WebInteropTest.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
3030
import kotlin.test.Test
3131
import kotlin.test.assertEquals
3232
import kotlin.test.assertFalse
33+
import kotlin.test.assertNotEquals
3334
import kotlin.test.assertNotNull
3435
import kotlin.test.assertNull
3536
import kotlin.test.assertTrue
@@ -64,13 +65,13 @@ class WebInteropTest : OnCanvasTests {
6465
}
6566

6667

67-
var div = getShadowRoot().getElementById(divId) as HTMLDivElement?
68+
var div = document.getElementById(divId) as HTMLDivElement?
6869
assertNull(div)
6970

7071
showDiv.value = true
7172
awaitIdle()
7273

73-
div = getShadowRoot().getElementById(divId) as HTMLDivElement?
74+
div = document.getElementById(divId) as HTMLDivElement?
7475
assertNotNull(div)
7576
assertTrue(div.isConnected)
7677
assertEquals("Text1", div.innerText)
@@ -166,16 +167,17 @@ class WebInteropTest : OnCanvasTests {
166167

167168
@Test
168169
fun hitPath() = runApplicationTest {
170+
val divId = "interop_div"
169171
createComposeWindow {
170172
Box(modifier = Modifier.size(100.dp), contentAlignment = Alignment.Center) {
171-
TestInteropView(Modifier.size(30.dp), "div")
173+
TestInteropView(Modifier.size(30.dp), divId)
172174
}
173175
}
174176
awaitIdle()
175177

176-
assertEquals("CANVAS", getShadowRoot().elementFromPoint(10.0, 10.0).tagName)
177-
assertEquals("DIV", getShadowRoot().elementFromPoint(50.0, 50.0).tagName)
178-
assertEquals("CANVAS", getShadowRoot().elementFromPoint(90.0, 90.0).tagName)
178+
assertNotEquals(divId, document.elementFromPoint(10.0, 10.0)?.id)
179+
assertEquals(divId, document.elementFromPoint(50.0, 50.0)?.id)
180+
assertNotEquals(divId, document.elementFromPoint(90.0, 90.0)?.id)
179181
}
180182
}
181183

0 commit comments

Comments
 (0)