Skip to content

Commit 025c1b5

Browse files
committed
Dispose of web compose window when removed from document
1 parent e3a6676 commit 025c1b5

File tree

2 files changed

+41
-33
lines changed

2 files changed

+41
-33
lines changed

compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ import org.w3c.dom.HTMLElement
9898
import org.w3c.dom.HTMLStyleElement
9999
import org.w3c.dom.HTMLTitleElement
100100
import org.w3c.dom.MediaQueryListEvent
101+
import org.w3c.dom.MutationObserver
102+
import org.w3c.dom.MutationObserverInit
101103
import org.w3c.dom.OPEN
104+
import org.w3c.dom.ShadowRoot
102105
import org.w3c.dom.ShadowRootInit
103106
import org.w3c.dom.ShadowRootMode
104107
import org.w3c.dom.TouchEvent
@@ -203,6 +206,14 @@ internal class ComposeWindow(
203206

204207
private val canvasEvents = EventTargetListener(canvas)
205208

209+
private val detachListener = MutationObserver { _, _ ->
210+
val root = canvas.getRootNode()
211+
val queryElement = if (root is ShadowRoot) root.host else canvas
212+
if (!document.body!!.contains(queryElement)) {
213+
dispose()
214+
}
215+
}
216+
206217
private var keyboardModeState: KeyboardModeState = KeyboardModeState.Hardware
207218

208219
private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty {
@@ -407,6 +418,8 @@ internal class ComposeWindow(
407418
else Lifecycle.Event.ON_STOP
408419
)
409420
}
421+
422+
detachListener.observe(document.body!!, MutationObserverInit(childList = true, subtree = true))
410423
}
411424

412425
init {
@@ -472,9 +485,11 @@ internal class ComposeWindow(
472485
skiaLayer.needRedraw()
473486
}
474487

475-
// TODO: need to call .dispose() on window close.
476488
fun dispose() {
477489
check(!isDisposed)
490+
491+
detachListener.disconnect()
492+
478493
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
479494
viewModelStore.clear()
480495

compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,53 +21,46 @@ import androidx.compose.ui.sendFromScope
2121
import androidx.lifecycle.Lifecycle
2222
import androidx.lifecycle.LifecycleEventObserver
2323
import androidx.lifecycle.LifecycleOwner
24-
import kotlin.test.Ignore
24+
import androidx.lifecycle.compose.LocalLifecycleOwner
2525
import kotlin.test.Test
2626
import kotlin.test.assertEquals
27-
import kotlin.test.assertTrue
28-
import kotlinx.browser.document
2927
import kotlinx.browser.window
3028
import kotlinx.coroutines.channels.Channel
3129
import kotlinx.coroutines.test.runTest
32-
import org.w3c.dom.HTMLDivElement
30+
import org.w3c.dom.events.Event
3331

3432
class ComposeWindowLifecycleTest : OnCanvasTests {
3533
@Test
36-
@Ignore // ignored while investigating CI issues: this test opens a new browser window which can be the cause
3734
fun allEvents() = runTest {
38-
val canvas = getCanvas()
39-
canvas.focus()
40-
41-
val lifecycleOwner = ComposeWindow(
42-
canvas = canvas,
43-
interopContainerElement = document.createElement("div") as HTMLDivElement,
44-
a11yContainerElement = null,
45-
content = {},
46-
configuration = ComposeViewportConfiguration(),
47-
state = DefaultWindowState(document.documentElement!!)
48-
)
49-
5035
val eventsChannel = Channel<Lifecycle.Event>(10)
51-
52-
lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver {
53-
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
54-
eventsChannel.sendFromScope(event)
55-
}
56-
})
36+
createComposeWindow {
37+
val lifecycle = LocalLifecycleOwner.current.lifecycle
38+
lifecycle.addObserver(object : LifecycleEventObserver {
39+
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
40+
eventsChannel.sendFromScope(event)
41+
}
42+
})
43+
}
5744

5845
assertEquals(Lifecycle.State.CREATED, eventsChannel.receive().targetState)
5946
assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState)
60-
assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState)
6147

62-
// Browsers don't allow to blur the window from code:
63-
// https://developer.mozilla.org/en-US/docs/Web/API/Window/blur
64-
// So we simulate a new tab being open:
65-
val anotherWindow = window.open("about:config")
66-
assertTrue(anotherWindow != null)
48+
// Dispatch artificial events that would be sent when the window gains and loses focus.
49+
// Starting with a focus event before checking for the initial RESUMED makes this test
50+
// robust in the face of both an already-focused window and a non-focused window. Then,
51+
// a blur plus focus cycle simulates losing focus and regaining it.
52+
window.dispatchEvent(Event("focus"))
53+
assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState)
54+
window.dispatchEvent(Event("blur"))
6755
assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState)
68-
69-
// Now go back to the original window
70-
anotherWindow.close()
56+
window.dispatchEvent(Event("focus"))
7157
assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState)
58+
59+
// Destroy the ComposeWindow by removing its host container from the DOM.
60+
val host = getShadowRoot().host
61+
host.parentNode?.removeChild(host)
62+
assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState)
63+
assertEquals(Lifecycle.State.CREATED, eventsChannel.receive().targetState)
64+
assertEquals(Lifecycle.State.DESTROYED, eventsChannel.receive().targetState)
7265
}
7366
}

0 commit comments

Comments
 (0)