diff --git a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt index bd073fad0057b..e3e79ab931790 100644 --- a/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt +++ b/compose/ui/ui/src/webCommonW3C/kotlin/androidx/compose/ui/window/ComposeWindow.w3c.kt @@ -98,7 +98,10 @@ import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLStyleElement import org.w3c.dom.HTMLTitleElement import org.w3c.dom.MediaQueryListEvent +import org.w3c.dom.MutationObserver +import org.w3c.dom.MutationObserverInit import org.w3c.dom.OPEN +import org.w3c.dom.ShadowRoot import org.w3c.dom.ShadowRootInit import org.w3c.dom.ShadowRootMode import org.w3c.dom.TouchEvent @@ -203,6 +206,14 @@ internal class ComposeWindow( private val canvasEvents = EventTargetListener(canvas) + private val detachListener = MutationObserver { _, _ -> + val root = canvas.getRootNode() + val queryElement = if (root is ShadowRoot) root.host else canvas + if (!document.body!!.contains(queryElement)) { + dispose() + } + } + private var keyboardModeState: KeyboardModeState = KeyboardModeState.Hardware private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty { @@ -407,6 +418,8 @@ internal class ComposeWindow( else Lifecycle.Event.ON_STOP ) } + + detachListener.observe(document.body!!, MutationObserverInit(childList = true, subtree = true)) } init { @@ -472,9 +485,11 @@ internal class ComposeWindow( skiaLayer.needRedraw() } - // TODO: need to call .dispose() on window close. fun dispose() { check(!isDisposed) + + detachListener.disconnect() + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) viewModelStore.clear() diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt index 013c0bbb12c2e..ad9c75eabdba8 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/window/ComposeWindowLifecycleTest.kt @@ -21,53 +21,46 @@ import androidx.compose.ui.sendFromScope import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import kotlin.test.Ignore +import androidx.lifecycle.compose.LocalLifecycleOwner import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlinx.browser.document import kotlinx.browser.window import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.runTest -import org.w3c.dom.HTMLDivElement +import org.w3c.dom.events.Event class ComposeWindowLifecycleTest : OnCanvasTests { @Test - @Ignore // ignored while investigating CI issues: this test opens a new browser window which can be the cause fun allEvents() = runTest { - val canvas = getCanvas() - canvas.focus() - - val lifecycleOwner = ComposeWindow( - canvas = canvas, - interopContainerElement = document.createElement("div") as HTMLDivElement, - a11yContainerElement = null, - content = {}, - configuration = ComposeViewportConfiguration(), - state = DefaultWindowState(document.documentElement!!) - ) - val eventsChannel = Channel(10) - - lifecycleOwner.lifecycle.addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - eventsChannel.sendFromScope(event) - } - }) + createComposeWindow { + val lifecycle = LocalLifecycleOwner.current.lifecycle + lifecycle.addObserver(object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + eventsChannel.sendFromScope(event) + } + }) + } assertEquals(Lifecycle.State.CREATED, eventsChannel.receive().targetState) assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState) - assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState) - // Browsers don't allow to blur the window from code: - // https://developer.mozilla.org/en-US/docs/Web/API/Window/blur - // So we simulate a new tab being open: - val anotherWindow = window.open("about:config") - assertTrue(anotherWindow != null) + // Dispatch artificial events that would be sent when the window gains and loses focus. + // Starting with a focus event before checking for the initial RESUMED makes this test + // robust in the face of both an already-focused window and a non-focused window. Then, + // a blur plus focus cycle simulates losing focus and regaining it. + window.dispatchEvent(Event("focus")) + assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState) + window.dispatchEvent(Event("blur")) assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState) - - // Now go back to the original window - anotherWindow.close() + window.dispatchEvent(Event("focus")) assertEquals(Lifecycle.State.RESUMED, eventsChannel.receive().targetState) + + // Destroy the ComposeWindow by removing its host container from the DOM. + val host = getShadowRoot().host + host.parentNode?.removeChild(host) + assertEquals(Lifecycle.State.STARTED, eventsChannel.receive().targetState) + assertEquals(Lifecycle.State.CREATED, eventsChannel.receive().targetState) + assertEquals(Lifecycle.State.DESTROYED, eventsChannel.receive().targetState) } }