diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt index 38f2466fa74bc..1e707aa071aa5 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/Utils.desktop.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntRect import java.awt.Component +import java.awt.EventQueue import java.awt.Rectangle import javax.swing.JComponent import kotlin.math.ceil @@ -88,3 +89,16 @@ internal fun JComponent.setTransparent(transparent: Boolean) { isOpaque = false } } + +/** + * Calls [block] synchronously on the event dispatching thread. If the calling thread is already the + * event dispatch thread, simply executes [block]; otherwise schedules [block] to run on event + * dispatching thread and waits for it to return. + */ +internal fun runOnEDTThread(block: () -> Unit) { + if (EventQueue.isDispatchThread()) { + block() + } else { + EventQueue.invokeAndWait(block) + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt index 04c3b0d52fd30..cef30ad0ac1f9 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.awt.AwtEventListener import androidx.compose.ui.awt.AwtEventListeners import androidx.compose.ui.awt.OnlyValidPrimaryMouseButtonFilter import androidx.compose.ui.awt.SwingInteropContainer +import androidx.compose.ui.awt.runOnEDTThread import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.geometry.Offset @@ -492,9 +493,11 @@ internal class ComposeSceneMediator( } } - fun onComposeInvalidation() = catchExceptions { - if (isDisposed) return - skiaLayerComponent.onComposeInvalidation() + fun onComposeInvalidation() = runOnEDTThread { + catchExceptions { + if (isDisposed) return@catchExceptions + skiaLayerComponent.onComposeInvalidation() + } } fun onChangeComponentPosition() = catchExceptions { diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ComposeSceneTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ComposeSceneTest.kt index 4513292e8951c..25547f6f9e3bf 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ComposeSceneTest.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/ComposeSceneTest.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester @@ -79,11 +80,17 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyPress import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.runApplicationTest import com.google.common.truth.Truth.assertThat import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.junit.Assert.assertFalse import org.junit.Ignore import org.junit.Rule @@ -638,6 +645,40 @@ class ComposeSceneTest { } } + @Test + fun stateChangeFromNonUiThreadDoesntCrash() = runApplicationTest { + // https://github.com/JetBrains/compose-multiplatform/issues/4546 + var value by mutableStateOf(0) + val done = CompletableDeferred() + var exceptionThrown: Throwable? = null + + launchTestApplication { + Window(onCloseRequest = {}) { + Canvas(Modifier.size(100.dp)) { + @Suppress("UNUSED_EXPRESSION") + value + } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + try { + for (i in 1..50) { + value = i + Snapshot.sendApplyNotifications() + delay(1) + } + } catch (e: Throwable) { + exceptionThrown = e + } + done.complete(Unit) + } + } + } + } + + done.await() + assertNull(exceptionThrown, "Exception thrown setting snapshot state from non-UI thread") + } + private class TestException : RuntimeException() @ExperimentalComposeUiApi