From dee4e8d8ba71981116d326790f4494ba0d27803e Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Mon, 15 Jun 2026 20:20:32 +0200 Subject: [PATCH 01/12] Avoid Rect allocations on extremely hot paths. (each frame, single or multiple times) --- .../androidx/compose/ui/node/RootNodeOwner.skiko.kt | 3 ++- .../kotlin/androidx/compose/ui/unit/Geometry.skiko.kt | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index b8ccca31e031e..2833ebbb6f2ad 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -91,6 +91,7 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.contains import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toRect import androidx.compose.ui.useLegacyRenderNodeLayers @@ -393,7 +394,7 @@ internal class RootNodeOwner( } private fun isInBounds(localPosition: Offset): Boolean = - size?.toRect()?.contains(localPosition) ?: true + size?.contains(localPosition) ?: true private fun calculateBoundsInWindow(): Rect? { val rect = size?.toRect() ?: return null diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt index 1d822347d14bb..74cebb4867ccd 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt @@ -98,6 +98,17 @@ internal inline fun DpSize.coerceAtMost(size: DpSize): DpSize = internal inline fun IntSize.toRect(): Rect = Rect(0f, 0f, width.toFloat(), height.toFloat()) +/** + * Returns true if the given [offset] is contained within this [IntSize] and (0,0) + * This is used to avoid [Rect] object allocations on hot paths + */ +@Stable +internal inline fun IntSize.contains(offset: Offset): Boolean { + val offsetY = offset.y + val offsetX = offset.x + return (offsetX >= 0f) and (offsetX < width) and (offsetY >= 0f) and (offsetY < height) +} + @Stable internal fun IntSize.toDpSize(density: Density): DpSize { with(density) { From e7192788cc4d6fdee5f270b39a0f6bf747ab0d11 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Tue, 16 Jun 2026 00:37:50 +0200 Subject: [PATCH 02/12] Reduce number of copies in SyntheticEventSender and use Primitive Collections to avoid boxing, iterators and improve performance. (Similar change to PointerToPositionMap) --- .../pointer/SyntheticEventSender.skiko.kt | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt index b3016f995026e..799910353f45b 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt @@ -17,13 +17,16 @@ package androidx.compose.ui.input.pointer import androidx.collection.LongLongMap +import androidx.collection.MutableLongList +import androidx.collection.MutableLongSet import androidx.collection.buildLongLongMap +import androidx.collection.buildLongSet +import androidx.collection.mutableLongSetOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.scene.PointerEventResult import androidx.compose.ui.scene.merging import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastFilteredMap import androidx.compose.ui.util.fastFirstOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap @@ -124,6 +127,7 @@ internal class SyntheticEventSender( PointerEventType.PanMove, PointerEventType.PanEnd, -> isMousePointerInside = true + PointerEventType.Exit -> isMousePointerInside = false } @@ -139,7 +143,7 @@ internal class SyntheticEventSender( // modifiers as the previous event. // Note that missing move events for this event should have already been sent fun areSameParams(e1: PointerInputEvent, e2: PointerInputEvent): Boolean { - if (e1.pressedIds().toSet() != e2.pressedIds().toSet()) return false + if (e1.pressedIds() != e2.pressedIds()) return false if (e1.buttons != e2.buttons) return false if (e1.keyboardModifiers != e2.keyboardModifiers) return false return true @@ -224,8 +228,8 @@ internal class SyntheticEventSender( val previousEvent = previousEvent ?: return UnconsumedEventResult val previousPressed = previousEvent.pressedIds() val currentPressed = currentEvent.pressedIds() - val newReleased = (previousPressed - currentPressed.toSet()).toList() - val sendingAsUp = HashSet(newReleased.size) + val newReleased = previousPressed - currentPressed + val sendingAsUp = PointerIdSet(newReleased.size) var result = UnconsumedEventResult val lastIndex = when (currentEvent.eventType) { @@ -253,10 +257,10 @@ internal class SyntheticEventSender( } private fun sendMissingPresses(currentEvent: PointerInputEvent): PointerEventResult { - val previousPressed = previousEvent?.pressedIds().orEmpty().toSet() + val previousPressed = previousEvent?.pressedIds()?.toSet() ?: mutableLongSetOf() val currentPressed = currentEvent.pressedIds() - val newPressed = (currentPressed - previousPressed).toList() - val sendingAsDown = HashSet(newPressed.size) + val newPressed = currentPressed - previousPressed + val sendingAsDown = PointerIdSet(newPressed.size) var result = UnconsumedEventResult val lastIndex = when (currentEvent.eventType) { @@ -283,10 +287,6 @@ internal class SyntheticEventSender( return result } - private fun PointerInputEvent.pressedIds(): List = - pointers.fastFilteredMap(PointerInputEventData::down, PointerInputEventData::id) - - private fun sendInternal(event: PointerInputEvent): PointerEventResult { when (event.eventType) { PointerEventType.ScaleStart -> isScaleGestureInProgress = true @@ -414,6 +414,47 @@ internal class SyntheticEventSender( private typealias PointerToPositionMap = LongLongMap +private typealias PointerIdSet = MutableLongSet + +private typealias PointerIdList = MutableLongList + +private fun PointerInputEvent.pressedIds(): PointerIdList { + val target = MutableLongList(pointers.size) + pointers.fastForEach { + if (it.down) target += it.id.value + } + return target +} + +private operator fun PointerIdList.minus(elements: PointerIdList): PointerIdList = + when { + elements.isEmpty() -> this + elements.size > 16 -> { + //Optimizing for when the element list is large, converting to a Set is cheaper than repeated O(N) contains checks + val set = elements.toSet() + filter { it !in set } + } + else -> filter { it !in elements } + } + +private inline fun PointerIdList.filter(predicate: (Long) -> Boolean): PointerIdList { + val target = MutableLongList(size) + forEach { if (predicate(it)) target += it } + return target +} + +@Suppress("NOTHING_TO_INLINE") +private inline operator fun PointerIdSet.contains(id: PointerId): Boolean = contains(id.value) + +private fun PointerIdList.toSet(): PointerIdSet = buildLongSet(size) { + forEach { add(it) } +} as PointerIdSet //Safe cast + +internal operator fun PointerIdList.minus(elements: PointerIdSet): PointerIdList { + if (elements.isEmpty()) return this + return filter { it !in elements } +} + private fun List.mapPointersToPosition(): PointerToPositionMap = buildLongLongMap(size) { this@mapPointersToPosition.fastForEach { ptr -> From 1c41dbc0fb1ca3587dd39a759cfdf70e20119610 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 26 Jun 2026 20:08:57 +0200 Subject: [PATCH 03/12] Remove already set attributes --- .../kotlin/androidx/compose/ui/window/ComposeWindow.web.kt | 1 + .../androidx/compose/ui/window/ComposeWindowInternal.web.kt | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index c750aa75bb3a4..e8500d18055dd 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -159,6 +159,7 @@ fun ComposeViewport( val canvas = document.createElement("canvas") as HTMLCanvasElement canvas.setAttribute("tabindex", "0") canvas.setAttribute("role", "generic") + canvas.setAttribute("draggable", "true") canvas.style.outline = "none" // Fixes https://youtrack.jetbrains.com/issue/CMP-9040 canvas.style.setProperty("touch-action", "none") //blocks default browser touch handling appContainer.appendChild(canvas) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 18b310c246197..3dbf3f4e30ced 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -476,8 +476,6 @@ internal class ComposeWindow( initEvents(canvas) state.init() - canvas.setAttribute("tabindex", "0") - canvas.setAttribute("draggable", "true") scene.density = density archComponentsOwner.enableSavedStateHandles() From af3c79954022da19d5db8396c688f43a69344d65 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 26 Jun 2026 20:10:34 +0200 Subject: [PATCH 04/12] Move toMaxConstraints to Geometry (cleanup classes where it was repeated) --- .../kotlin/androidx/compose/ui/ImageComposeScene.skiko.kt | 7 +++---- .../kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt | 4 +--- .../kotlin/androidx/compose/ui/unit/Geometry.skiko.kt | 4 ++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ImageComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ImageComposeScene.skiko.kt index 96cf3cb35bba9..74927cf473791 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ImageComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ImageComposeScene.skiko.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.toDpSize +import androidx.compose.ui.unit.toMaxConstraints import androidx.compose.ui.unit.toSize import kotlin.coroutines.CoroutineContext import kotlin.jvm.JvmName @@ -231,7 +232,7 @@ class ImageComposeScene @ExperimentalComposeUiApi constructor( * Constraints used to measure and layout content. */ var constraints: Constraints - get() = scene.size?.toConstraints() ?: Constraints() + get() = scene.size.toMaxConstraints() set(value) { scene.size = value.toIntSize() } /** @@ -402,6 +403,4 @@ private fun Constraints.toIntSize() = IntSize(width = maxWidth, height = maxHeight) } else { null - } - -private fun IntSize.toConstraints() = Constraints(maxWidth = width, maxHeight = height) + } \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index 2833ebbb6f2ad..b1c30d1df3886 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -93,6 +93,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.contains import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.toMaxConstraints import androidx.compose.ui.unit.toRect import androidx.compose.ui.useLegacyRenderNodeLayers import androidx.compose.ui.util.fastAll @@ -1003,9 +1004,6 @@ internal class RootNodeOwner( } } -private fun IntSize?.toMaxConstraints() = - if (this == null) Constraints() else Constraints(maxWidth = width, maxHeight = height) - private object IdentityPositionCalculator : PositionCalculator { override fun screenToLocal(positionOnScreen: Offset): Offset = positionOnScreen override fun localToScreen(localPosition: Offset): Offset = localPosition diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt index 74cebb4867ccd..8fce0c8044605 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/unit/Geometry.skiko.kt @@ -161,3 +161,7 @@ internal fun DpOffset.requireReal(): DpOffset { y.requireReal("y") return this } + +@Stable +internal inline fun IntSize?.toMaxConstraints() = + if (this == null) Constraints() else Constraints(maxWidth = width, maxHeight = height) From 23fd55770789db699594d93d4be74d95c2bb908a Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 26 Jun 2026 20:11:41 +0200 Subject: [PATCH 05/12] Change to fastForEach (under the hood it is exactly the same) and move dirtyLayers.clear() inside the dirtyLayers.isNotEmpty() check as it does not make sense to clear if dirtyLayers is empty --- .../kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index b1c30d1df3886..3895226bcb50f 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -97,6 +97,7 @@ import androidx.compose.ui.unit.toMaxConstraints import androidx.compose.ui.unit.toRect import androidx.compose.ui.useLegacyRenderNodeLayers import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.trace import androidx.compose.ui.viewinterop.InteropPointerInputModifier import androidx.compose.ui.viewinterop.InteropView @@ -974,12 +975,11 @@ internal class RootNodeOwner( // So, we applying it before drawing to reflect the changes from previous phases. // Changes that requires another round of invalidation will be scheduled to next frame. if (dirtyLayers.isNotEmpty()) { - for (i in 0 until dirtyLayers.size) { - val layer = dirtyLayers[i] + dirtyLayers.fastForEach { layer -> layer.updateDisplayList() } + dirtyLayers.clear() } - dirtyLayers.clear() // Draw root node owner.root.draw( From 0e75944844e7dbbb8cac202d3ae3788c853e07c9 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 26 Jun 2026 20:20:18 +0200 Subject: [PATCH 06/12] Revert unwanted formatting --- .../compose/ui/input/pointer/SyntheticEventSender.skiko.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt index 799910353f45b..7b29438ebf287 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt @@ -127,7 +127,6 @@ internal class SyntheticEventSender( PointerEventType.PanMove, PointerEventType.PanEnd, -> isMousePointerInside = true - PointerEventType.Exit -> isMousePointerInside = false } From 0c968fb686fc50552da694935987c52073ea99f5 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Sat, 27 Jun 2026 22:55:19 +0200 Subject: [PATCH 07/12] Mini fix for startup performance. I observed that previous remove method was iterating over a js value API in a kotlin loop and removing first child. This could cause freezes of up to 50 ms (observed during high-usage memory in a device). I observed a 5.8% reduction in time between main function call and Compose being composed, which amounted to around 7-8 ms (a frame, in my device). Also, the method used has been baseline since 2020 and it is the preferred way as it avoids crossing wasm to Js boundary over and over several times --- .../androidx/compose/ui/window/ComposeWindow.web.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index e8500d18055dd..ed87ccf641d05 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -19,7 +19,6 @@ package androidx.compose.ui.window import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import kotlinx.browser.document -import kotlinx.dom.clear import org.w3c.dom.Element import org.w3c.dom.HTMLCanvasElement import org.w3c.dom.HTMLDivElement @@ -84,8 +83,8 @@ fun ComposeViewport( configure: ComposeViewportConfiguration.() -> Unit = {}, content: @Composable () -> Unit = { } ) = onSkikoReady { - viewportContainer.clear() + clearNodeChildren(viewportContainer) // Create a common positioning container (parent html element) for shadow and the interop containers // to position at the same place - the interop container is position at 0,0 relative to the shadow. // It simplifies the positioning of the interop views in the container. @@ -198,4 +197,8 @@ fun ComposeViewport( configuration = configuration, state = DefaultWindowState(viewportContainer) ) -} \ No newline at end of file +} + +private fun clearNodeChildren(node: Element): Unit = + //language=JavaScript + js("node.replaceChildren()") From 11ce690e370fd719cd0f4fd15486858cfa46e3f9 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Sun, 28 Jun 2026 23:09:49 +0200 Subject: [PATCH 08/12] Avoid getting same values several times, especially if on the wasm2js interop border --- .../compose/ui/window/ComposeWindowInternal.web.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 3dbf3f4e30ced..2cf8fdbf0c80b 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -747,11 +747,14 @@ internal class ComposeWindow( // for us (report deltaX instead of deltaY), some don't. val horizontalScroll: Double val verticalScroll: Double - if (event.shiftKey && event.deltaX == 0.0) { + val isShifting = event.shiftKey + val deltaX = event.deltaX + + if (isShifting && deltaX == 0.0) { horizontalScroll = event.deltaY verticalScroll = 0.0 } else { - horizontalScroll = event.deltaX + horizontalScroll = deltaX verticalScroll = event.deltaY } @@ -771,7 +774,7 @@ internal class ComposeWindow( isCtrlPressed = event.ctrlKey, isMetaPressed = event.metaKey, isAltPressed = event.altKey, - isShiftPressed = event.shiftKey, + isShiftPressed = isShifting, ), nativeEvent = event, button = event.composeButton, From 79b33523681d8eb5e0edb01f77b35489ddedea4e Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Sun, 28 Jun 2026 23:29:12 +0200 Subject: [PATCH 09/12] Avoid the boxing of value classes when using their nullables. Using instead Unspecified and Zero versions of it (compiles down to a simple primitive) --- .../compose/ui/platform/WebTextInputService.kt | 6 +++--- .../ui/window/ComposeWindowInternal.web.kt | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebTextInputService.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebTextInputService.kt index 8047801e10c30..79b3bef564018 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebTextInputService.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebTextInputService.kt @@ -58,7 +58,7 @@ internal abstract class WebTextInputService : * We need the correct position, so the software keyboard wouldn't overlap the text input. * See https://youtrack.jetbrains.com/issue/CMP-8611 for details. */ - internal open val currentTouchOffset: Offset? = null + internal open val currentTouchOffset: Offset = Offset.Unspecified /** * This container will host the actual hidden HTML input element. @@ -86,12 +86,12 @@ internal abstract class WebTextInputService : ) backingDomInput?.register() - if (currentTouchOffset != null) { + if (currentTouchOffset != Offset.Unspecified) { // We don't know the real position yet, but it's reasonable to assume that // if startInput is caused by a touch event, // then the TextField is positioned around the touch coordinates. // See the currentTouchOffset KDoc for the details. - notifyFocusedRect(Rect(currentTouchOffset!!, Size(1f, 1f))) + notifyFocusedRect(Rect(currentTouchOffset, Size(1f, 1f))) } showSoftwareKeyboard() } diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 2cf8fdbf0c80b..7b089282ed20d 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -194,7 +194,7 @@ internal class ComposeWindow( ) { private var isDisposed = false - private var actualActivePointerButtons: PointerButtons? = null + private var actualActivePointerButtons: PointerButtons = PointerButtons() private val density: Density = Density( density = actualDensity.toFloat(), @@ -215,7 +215,7 @@ internal class ComposeWindow( private var keyboardModeState: KeyboardModeState = KeyboardModeState.Hardware // Used in WebTextInputService. Also see https://youtrack.jetbrains.com/issue/CMP-8611 - private var activeTouchOffset: Offset? = null + private var activeTouchOffset: Offset = Offset.Unspecified private val clipTarget = clipTargetElement(canvas) @@ -288,7 +288,7 @@ internal class ComposeWindow( override val textInputService: WebTextInputService by lazy(LazyThreadSafetyMode.NONE) { object : WebTextInputService() { - override val currentTouchOffset: Offset? + override val currentTouchOffset: Offset get() = activeTouchOffset override val backingDomInputContainer: HTMLElement @@ -418,7 +418,7 @@ internal class ComposeWindow( state.globalEvents.addDisposableEvent("dragend") { // in Safari pointerup event is not firing when we drop or cancel drop // see https://youtrack.jetbrains.com/issue/CMP-10102 - actualActivePointerButtons = null + actualActivePointerButtons = PointerButtons() } addTypedEvent("touchstart") { evt -> @@ -605,9 +605,9 @@ internal class ComposeWindow( if (event.type == "pointercancel") { if (isTouchEvent(event)) { activeTouchPointers.clear() - activeTouchOffset = null + activeTouchOffset = Offset.Unspecified } else { - actualActivePointerButtons = null + actualActivePointerButtons = PointerButtons() } event.target?.let { releasePointerCapture(it, event.pointerId) } @@ -630,7 +630,7 @@ internal class ComposeWindow( actualActivePointerButtons = event.composeButtons } PointerEventType.Release -> { - actualActivePointerButtons = null + actualActivePointerButtons = PointerButtons() } } @@ -726,7 +726,7 @@ internal class ComposeWindow( ) } - activeTouchOffset = null + activeTouchOffset = Offset.Unspecified if (eventType == PointerEventType.Release) { activeTouchPointers.remove(event.pointerId) @@ -760,7 +760,7 @@ internal class ComposeWindow( // wheels event own buttons property is unreliable in Safari and Firefox // see CMP-9900 [web] Wheel event resolves buttons state incorrectly in Safari and Firefox - val buttons = actualActivePointerButtons ?: event.composeButtons + val buttons = if(actualActivePointerButtons != PointerButtons()) actualActivePointerButtons else event.composeButtons val result = scene.sendPointerEvent( eventType = PointerEventType.Scroll, From 22d290763123a4d4f1c858bf1835bff66344766f Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Sun, 28 Jun 2026 23:43:45 +0200 Subject: [PATCH 10/12] Cache lambdas in 'passed to Js listeners' to avoid overallocating on init. (onPointerCallback was being created 6 times on init, delaying scene startup). It also avoid an iterator allocation on init --- .../compose/ui/window/ComposeWindowInternal.web.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 7b089282ed20d..22d62913d13a4 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -404,6 +404,7 @@ internal class ComposeWindow( private fun initEvents(canvas: HTMLCanvasElement) { + val onPointerCallback: (PointerEvent) -> Unit = { onPointerEvent(it) } listOf( "pointerenter", "pointerdown", @@ -411,8 +412,8 @@ internal class ComposeWindow( "pointerup", "pointerleave", "pointercancel" - ).forEach { name -> - addTypedEvent(name, passive = false) { onPointerEvent(it) } + ).fastForEach { name -> + addTypedEvent(name, passive = false, handler = onPointerCallback) } state.globalEvents.addDisposableEvent("dragend") { @@ -440,13 +441,11 @@ internal class ComposeWindow( event.preventDefault() }) - addTypedEvent("keydown") { event -> - processKeyboardEvent(event) - } - - addTypedEvent("keyup") { event -> + val onKeyboardEventCallback: (KeyboardEvent) -> Unit = { event -> processKeyboardEvent(event) } + addTypedEvent("keydown", onKeyboardEventCallback) + addTypedEvent("keyup", onKeyboardEventCallback) addTypedEvent("focus") { event -> canvasFocused = true From 62c9cf4f562dfd3b128c1f13348ae2e67c06951f Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Mon, 29 Jun 2026 01:35:58 +0200 Subject: [PATCH 11/12] Fixed logic error which caused tests to fail. The receiver param came from the buildLongSet method instead of the outer extension function, causing the Set to be empty each time --- .../compose/ui/input/pointer/SyntheticEventSender.skiko.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt index 7b29438ebf287..50564dbaf0c5f 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/input/pointer/SyntheticEventSender.skiko.kt @@ -446,7 +446,7 @@ private inline fun PointerIdList.filter(predicate: (Long) -> Boolean): PointerId private inline operator fun PointerIdSet.contains(id: PointerId): Boolean = contains(id.value) private fun PointerIdList.toSet(): PointerIdSet = buildLongSet(size) { - forEach { add(it) } + this@toSet.forEach { add(it) } } as PointerIdSet //Safe cast internal operator fun PointerIdList.minus(elements: PointerIdSet): PointerIdList { From 730658d2ef069188a244d2be2da902490fe6dd48 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Wed, 1 Jul 2026 22:53:21 +0200 Subject: [PATCH 12/12] Add check to see if node has children before calling replaceChildren on it --- .../androidx/compose/ui/window/ComposeWindow.web.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt index 03c493b13cf80..8c76cccbc220d 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindow.web.kt @@ -201,4 +201,10 @@ fun ComposeViewport( private fun clearNodeChildren(node: Element): Unit = //language=JavaScript - js("node.replaceChildren()") + js( + """ + { + if (node.hasChildNodes()) node.replaceChildren(); + } + """ + ) \ No newline at end of file