From 663e2d1864926f2acc332b70fe373707096069fc Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Fri, 10 Jul 2026 14:58:45 +0200 Subject: [PATCH] [JEWEL-1359] Fix Compose paste semantics traversal threading Compose paste action updates could traverse the Compose semantics tree from BGT even though Compose UI tree access is EDT-confined. This made action updates vulnerable to transient semantics tree inconsistencies while Compose was mutating layout state. Run the paste provider update on EDT and make focused semantics lookup fail closed for the observed transient nullability failure. The lookup still behaves as no focused Compose component being available, avoiding noisy action-update errors. Add focused coverage for the provider update thread and fail-closed semantics lookup behavior. closes https://github.com/JetBrains/intellij-community/pull/3570 (cherry picked from commit 27c0d0d293c6564330177a14513119ba8b37ac36) (cherry picked from commit dbf73d9a52504935b63530ce5ed8450d4833bfb4) IJ-MR-215743 GitOrigin-RevId: 5be520dc5115066833eaab6196f5bf430ed6081a --- .../actionSystem/ComposePasteProviderTest.kt | 32 +++++++++++++++++++ .../jewel/bridge/ComposeSemanticsTreeUtils.kt | 27 +++++++++++++--- .../actionSystem/ComposePasteProvider.kt | 3 +- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt diff --git a/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt b/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt new file mode 100644 index 000000000000..f4777cd78d24 --- /dev/null +++ b/platform/jewel/ide-laf-bridge/ide-laf-bridge-tests/src/test/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProviderTest.kt @@ -0,0 +1,32 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.bridge.actionSystem + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.progress.ProcessCanceledException +import org.jetbrains.jewel.bridge.ComposeSemanticsTreeUtils +import org.jetbrains.jewel.foundation.InternalJewelApi +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +@OptIn(InternalJewelApi::class) +public class ComposePasteProviderTest { + @Test + public fun `paste provider updates on EDT`() { + assertSame(ActionUpdateThread.EDT, ComposePasteProvider().actionUpdateThread) + } + + @Test + public fun `focused component lookup returns null when semantics traversal fails`() { + assertNull( + ComposeSemanticsTreeUtils.findFocusedComponent { + throw NullPointerException("transient Compose semantics tree mutation") + } + ) + } + + @Test(expected = ProcessCanceledException::class) + public fun `focused component lookup does not swallow cancellation`() { + ComposeSemanticsTreeUtils.findFocusedComponent { throw ProcessCanceledException() } + } +} diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt index 9dc847dfe0ff..48a76a3387a3 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/ComposeSemanticsTreeUtils.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getAllSemanticsNodes import androidx.compose.ui.semantics.getOrNull @@ -14,13 +15,29 @@ import org.jetbrains.jewel.foundation.InternalJewelApi @Internal @InternalJewelApi public object ComposeSemanticsTreeUtils { - public fun ComposePanel.findFocusedComponent(): SemanticsNode? { - return semanticsOwners.firstNotNullOfOrNull { o -> - o.getAllSemanticsNodes(mergingEnabled = true).firstOrNull { - it.config.getOrNull(SemanticsProperties.Focused) == true + /** + * Returns the currently focused semantics node from this [ComposePanel], or `null` if there is none. + * + * This lookup fail-closes when Compose reports a transiently inconsistent semantics tree during mutation. + */ + public fun ComposePanel.findFocusedComponent(): SemanticsNode? = findFocusedComponent { semanticsOwners } + + /** + * Returns the currently focused semantics node from [semanticsOwners]. + * + * The Compose semantics tree can be transiently inconsistent while it is being mutated, so this lookup fail-closes + * on [NullPointerException] and lets callers behave as if no focused Compose component is available. + */ + internal fun findFocusedComponent(semanticsOwners: () -> Iterable): SemanticsNode? = + try { + semanticsOwners().firstNotNullOfOrNull { o -> + o.getAllSemanticsNodes(mergingEnabled = true).firstOrNull { + it.config.getOrNull(SemanticsProperties.Focused) == true + } } + } catch (_: NullPointerException) { + null } - } public fun SemanticsNode.isEditableTextField(): Boolean { // Check if the node has editable text or supports setting text diff --git a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt index 15730180f139..890ab2c6b042 100644 --- a/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt +++ b/platform/jewel/ide-laf-bridge/src/main/kotlin/org/jetbrains/jewel/bridge/actionSystem/ComposePasteProvider.kt @@ -25,7 +25,8 @@ public class ComposePasteProvider : PasteProvider { override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext) - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + // This needs to run on the EDT, or we risk synchronization issues like JEWEL-1359 + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.EDT private fun getPasteAction(dataContext: DataContext): AccessibilityAction<() -> Boolean>? { val contextComponent = dataContext.getData(PlatformCoreDataKeys.CONTEXT_COMPONENT)