[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
This commit is contained in:
Sebastiano Poggi
2026-08-07 15:34:23 +00:00
committed by intellij-monorepo-bot
parent b0fb37244d
commit 663e2d1864
3 changed files with 56 additions and 6 deletions
@@ -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() }
}
}
@@ -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<SemanticsOwner>): 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
@@ -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)