From 132cbd76e150b32a238f0e9b1dd678279ea0d2a1 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Tue, 1 Sep 2026 20:27:06 +0200 Subject: [PATCH 01/12] Use specialized collections for better performance --- .../platform/accessibility/ComposeWebSemanticsListener.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index be67ef6ec7643..999e773980655 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -16,7 +16,9 @@ package androidx.compose.ui.platform.accessibility +import androidx.collection.MutableIntObjectMap import androidx.collection.MutableScatterMap +import androidx.collection.mutableObjectListOf import androidx.compose.ui.currentTimeMillis import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.PlatformContext @@ -139,7 +141,7 @@ internal class ComposeWebSemanticsListener( webSemanticsRoot.addEventListener("click", onClick) } - private val semanticsOwners = mutableListOf() + private val semanticsOwners = mutableObjectListOf() override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { if (semanticsOwners.contains(semanticsOwner)) return @@ -168,7 +170,7 @@ internal class ComposeWebSemanticsListener( private val dfsA11YParents = ArrayDeque() // Lookup maps between semantics nodes and corresponding A11Y DOM elements: - private val idToA11YNode = MutableScatterMap() + private val idToA11YNode = MutableIntObjectMap() private val a11yNodeToSemanticsNode = MutableScatterMap() // An intermediate tree representation which is applied to the actual DOM after every sync: @@ -208,7 +210,7 @@ internal class ComposeWebSemanticsListener( targetParentToChildren.clear() targetChildToParent.clear() - semanticsOwners.fastForEach { + semanticsOwners.forEach { syncSemanticsWithWebA11Y(it) } From 8a5c0350da18bcb02e4807e28a337d01e9f15c8e Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Tue, 1 Sep 2026 20:50:56 +0200 Subject: [PATCH 02/12] Use textContent for better performance as it avoids layout reflows --- .../platform/accessibility/ComposeWebSemanticsListener.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 999e773980655..815e5653e6af0 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -392,8 +392,8 @@ internal class ComposeWebSemanticsListener( text: String?, justCreated: Boolean = false, ) { - if (text != null && htmlNode.innerText != text) { - htmlNode.innerText = text + if (text != null && htmlNode.textContent != text) { + htmlNode.textContent = text } if (config.contains(SemanticsProperties.ContentDescription)) { @@ -408,8 +408,8 @@ internal class ComposeWebSemanticsListener( if (config.contains(SemanticsProperties.EditableText)) { val editableText = config[SemanticsProperties.EditableText].text - if (htmlNode.innerText != editableText) { - htmlNode.innerText = editableText + if (htmlNode.textContent != editableText) { + htmlNode.textContent = editableText } if (justCreated) { From 48f5e87833e140d1f02e7ae052115ea9e8aa6c83 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Thu, 3 Sep 2026 14:52:29 +0200 Subject: [PATCH 03/12] Change A11YScrollController.idToA11YNode to match the changed collection type in ComposeWebSemanticsListener --- .../compose/ui/platform/accessibility/A11YScrollUtils.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt index d7ee09dfa0077..0054e585b59e1 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.platform.accessibility +import androidx.collection.MutableIntObjectMap import androidx.collection.MutableIntSet import androidx.collection.MutableScatterMap import androidx.collection.ScatterMap @@ -44,7 +45,7 @@ import org.w3c.dom.events.Event * manipulate the scroll offset. */ internal class A11YScrollController( - private val idToA11YNode: ScatterMap, + private val idToA11YNode: MutableIntObjectMap, private val a11yNodeToSemanticsNode: ScatterMap, ) { From a4b8624535b731b4a993183d045cc320985ecf49 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Thu, 3 Sep 2026 15:05:49 +0200 Subject: [PATCH 04/12] Changed removedIds to non-boxing collection --- .../ui/platform/accessibility/ComposeWebSemanticsListener.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 815e5653e6af0..937e5bddcddac 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -18,6 +18,7 @@ package androidx.compose.ui.platform.accessibility import androidx.collection.MutableIntObjectMap import androidx.collection.MutableScatterMap +import androidx.collection.mutableIntSetOf import androidx.collection.mutableObjectListOf import androidx.compose.ui.currentTimeMillis import androidx.compose.ui.geometry.Offset @@ -220,7 +221,7 @@ internal class ComposeWebSemanticsListener( placeA11YChildrenInOrder(parent, targetChildren) } - val removedIds = mutableSetOf() + val removedIds = mutableIntSetOf() idToA11YNode.forEach { id, htmlNode -> if (!targetChildToParent.containsKey(htmlNode)) { From edab0454e4ad49a8692866722add459b1bd106d4 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Thu, 3 Sep 2026 15:34:18 +0200 Subject: [PATCH 05/12] Added support for more text related a11y information like MaxTextLength and editable vs disabled vs readonly --- .../ComposeWebSemanticsListener.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 937e5bddcddac..8c3cf209fd227 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.SemanticsConfiguration import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastJoinToString import kotlin.time.Duration.Companion.milliseconds @@ -407,21 +408,37 @@ internal class ComposeWebSemanticsListener( htmlNode.id = testTag } + val disabled = SemanticsProperties.Disabled in config + if (config.contains(SemanticsProperties.EditableText)) { val editableText = config[SemanticsProperties.EditableText].text if (htmlNode.textContent != editableText) { htmlNode.textContent = editableText } + val editable = config.getOrNull(SemanticsProperties.IsEditable) ?: false + htmlNode.setAttribute("contenteditable", editable.toString()) + + val readOnly = !editable && !disabled + htmlNode.setAttribute("aria-readonly", readOnly.toString()) + if (justCreated) { - htmlNode.setAttribute("contenteditable", "true") - htmlNode.addEventListener("focus", { + htmlNode.addEventListener("focus") { htmlNode.click() - }) + } } } - if (config.contains(SemanticsProperties.Disabled)) { + if (SemanticsProperties.MaxTextLength in config) { + val maxTextLength = config[SemanticsProperties.MaxTextLength] + if(maxTextLength > 0) { + htmlNode.setAttribute("maxlength", maxTextLength.toString()) + } + } else { + htmlNode.removeAttribute("maxlength") + } + + if (disabled) { htmlNode.setAttribute("aria-disabled", "true") } else { htmlNode.removeAttribute("aria-disabled") From 7f3ff0de38596d635255d397cd9a12f77258dc70 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Thu, 3 Sep 2026 15:36:34 +0200 Subject: [PATCH 06/12] Added support for Selected State ina11y for Web --- .../platform/accessibility/ComposeWebSemanticsListener.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 8c3cf209fd227..51fe4428e43e9 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -444,6 +444,13 @@ internal class ComposeWebSemanticsListener( htmlNode.removeAttribute("aria-disabled") } + if (SemanticsProperties.Selected in config) { + val selected = config[SemanticsProperties.Selected] + htmlNode.setAttribute("aria-selected", selected.toString()) + } else { + htmlNode.removeAttribute("aria-selected") + } + val roleId = config.getRoleId() setA11YAriaRole(element = htmlNode, roleId) From 352bf14d4baeaf14af7c1e015d3f5e8f457c217e Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 4 Sep 2026 00:12:51 +0200 Subject: [PATCH 07/12] add branch to remove readonly attribute --- .../platform/accessibility/ComposeWebSemanticsListener.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt index 51fe4428e43e9..b3fee105c68cf 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/ComposeWebSemanticsListener.kt @@ -420,7 +420,11 @@ internal class ComposeWebSemanticsListener( htmlNode.setAttribute("contenteditable", editable.toString()) val readOnly = !editable && !disabled - htmlNode.setAttribute("aria-readonly", readOnly.toString()) + if (readOnly) { + htmlNode.setAttribute("aria-readonly", readOnly.toString()) + } else { + htmlNode.removeAttribute("aria-readonly") + } if (justCreated) { htmlNode.addEventListener("focus") { From 20e3ffaa1cc2587174e1a82548dc51f1cae188c5 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 4 Sep 2026 01:03:48 +0200 Subject: [PATCH 08/12] Use transform instead of left/top to avoid layout reflows, which worsens performance --- .../accessibility/A11YImplementationUtils.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt index 87c8993c0e938..2ae9f624742c7 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt @@ -33,10 +33,19 @@ internal fun setSizeAndPosition( // language=javascript js( """ - element.style.left = "" + left + "px"; - element.style.top = "" + top + "px"; - element.style.width = "" + width + "px"; - element.style.height = "" + height + "px"; + const transformValue = 'matrix(1, 0, 0, 1, ' + left + ', ' + top + ')'; + const widthValue = "" + width + "px"; + const heightValue = "" + height + "px"; + + if (element.style.transform !== transformValue) { + element.style.transform = transformValue; + } + if (element.style.width !== widthValue) { + element.style.width = widthValue; + } + if (element.style.height !== heightValue) { + element.style.height = heightValue; + } """ ) } From 0b3147f3fd91964831ae628d92fb055af58d2718 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Fri, 4 Sep 2026 01:45:58 +0200 Subject: [PATCH 09/12] Replace collections by more performant ones in a11y scroll utils --- .../platform/accessibility/A11YScrollUtils.kt | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt index 0054e585b59e1..ad39d1e602584 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt @@ -16,9 +16,9 @@ package androidx.compose.ui.platform.accessibility +import androidx.collection.MutableIntLongMap import androidx.collection.MutableIntObjectMap import androidx.collection.MutableIntSet -import androidx.collection.MutableScatterMap import androidx.collection.ScatterMap import androidx.compose.ui.geometry.Offset import androidx.compose.ui.semantics.ScrollAxisRange @@ -51,16 +51,16 @@ internal class A11YScrollController( // When a browser (or AT) updates the scroll offset of the node in A11Y tree, we apply the // new offset to SemanticsNode and save the applied offset value here. - private val appliedScrollOffsets = MutableScatterMap() + private val appliedScrollOffsets = ScrollOffsetsByIdMap() // When we process the Semantics updates, we record the new scroll offsets here. // After the new offsets get applied to A11Y tree, the map is cleared. - private val pendingScrollOffsets = MutableScatterMap() + private val pendingScrollOffsets = ScrollOffsetsByIdMap() // The non-semantic elements inserted into A11Y scrollable nodes. // To make the scrollable a11y node aware of the possible scroll ranges, they include this "sizer" // element, which width/height equal to the scrollable viewport size + max scroll distance. - private val domScrollSizers = MutableScatterMap() + private val domScrollSizers = MutableIntObjectMap() // Tracking the nodes with a scroll listener: private val scrollListenersAttached = MutableIntSet() @@ -69,7 +69,7 @@ internal class A11YScrollController( private val onScroll: (Event) -> Unit = onScroll@ { event -> val element = event.target as? HTMLElement ?: return@onScroll val semanticsNode = a11yNodeToSemanticsNode[element] ?: return@onScroll - val applied = appliedScrollOffsets[semanticsNode.id] ?: return@onScroll + val applied = appliedScrollOffsets.getOffset(semanticsNode.id) ?: return@onScroll val actual = Offset(element.scrollLeft.toFloat(), element.scrollTop.toFloat()) val deltaCssPx = actual - applied @@ -102,8 +102,8 @@ internal class A11YScrollController( } fun getScrollOffset(semanticsNode: SemanticsNode): Offset { - return pendingScrollOffsets[semanticsNode.id] - ?: appliedScrollOffsets[semanticsNode.id] + return pendingScrollOffsets.getOffset(semanticsNode.id) + ?: appliedScrollOffsets.getOffset(semanticsNode.id) ?: Offset.Zero } @@ -192,7 +192,7 @@ internal class A11YScrollController( // Applies Compose scroll offsets to DOM fun applyScrollOffsets() { - pendingScrollOffsets.forEach { id, offset -> + pendingScrollOffsets.forEach { id, offset : Offset -> val element = idToA11YNode[id] ?: return@forEach val sizer = domScrollSizers[id] ?: return@forEach if (sizer.parentElement !== element || element.firstElementChild !== sizer) { @@ -202,7 +202,7 @@ internal class A11YScrollController( } val actual = Offset(element.scrollLeft.toFloat(), element.scrollTop.toFloat()) - val lastApplied = appliedScrollOffsets[id] + val lastApplied = appliedScrollOffsets.getOffset(id) if (lastApplied != null && offset.isCloseTo(lastApplied) && !actual.isCloseTo(lastApplied)) { // Preserve a browser/AT offset until its asynchronous scroll event is handled. return@forEach @@ -239,6 +239,27 @@ internal class A11YScrollController( } } +private typealias ScrollOffsetsByIdMap = MutableIntLongMap + +private inline fun ScrollOffsetsByIdMap.forEach(action: (id: Int, offset: Offset) -> Unit) = forEach { id, longValue -> + action(id, Offset(longValue)) +} + +@Suppress("NOTHING_TO_INLINE") +private inline operator fun ScrollOffsetsByIdMap.set(id: Int, offset: Offset) { + this[id] = offset.packedValue +} + +@Suppress("INVISIBLE_REFERENCE", "NOTHING_TO_INLINE") +private inline fun ScrollOffsetsByIdMap.getOffset(key: Int): Offset? { + val offset = this.getOrElse(key) { -1L } + return if (offset == -1L) { + null + } else { + Offset(values[key]) + } +} + // We don't expect such huge layouts in Compose (it's likely too expensive), so // keep synthetic scroll range well below known browser layout limits (>10kk). internal const val MAX_SUPPORTED_SCROLL_CSS_PX = 4_000_000f From 0a7484e65221c10b74ccb18c0679992be31611b3 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Tue, 8 Sep 2026 18:47:37 +0200 Subject: [PATCH 10/12] Fix ScrollOffsetsByIdMap.getOffset returning key instead of the value --- .../compose/ui/platform/accessibility/A11YScrollUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt index ad39d1e602584..7a0c8fc4b6af4 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YScrollUtils.kt @@ -256,7 +256,7 @@ private inline fun ScrollOffsetsByIdMap.getOffset(key: Int): Offset? { return if (offset == -1L) { null } else { - Offset(values[key]) + Offset(offset) } } From 985ca59934b5eb7023ede57f5a90afac8742d8a1 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Wed, 9 Sep 2026 16:43:43 +0200 Subject: [PATCH 11/12] Fix tests when commit changing Html node offsets from top/left to using style transforms, which does not participate in dom calculations, so overflows are not applied correctly --- .../accessibility/A11YImplementationUtils.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt index 2ae9f624742c7..a6e5d66030f76 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/accessibility/A11YImplementationUtils.kt @@ -30,15 +30,23 @@ import org.w3c.dom.HTMLElement internal fun setSizeAndPosition( element: HTMLElement, left: Float, top: Float, width: Float, height: Float ) { + // Note: the position must be set via left/top (not via a CSS transform). + // Transforms don't participate in the DOM layout: they break the layout-based geometry + // (offsetTop/offsetLeft), the scrollable overflow of the a11y scroll containers and + // the browser scroll anchoring, which ATs and browsers rely on. // language=javascript js( """ - const transformValue = 'matrix(1, 0, 0, 1, ' + left + ', ' + top + ')'; + const leftValue = "" + left + "px"; + const topValue = "" + top + "px"; const widthValue = "" + width + "px"; const heightValue = "" + height + "px"; - if (element.style.transform !== transformValue) { - element.style.transform = transformValue; + if (element.style.left !== leftValue) { + element.style.left = leftValue; + } + if (element.style.top !== topValue) { + element.style.top = topValue; } if (element.style.width !== widthValue) { element.style.width = widthValue; From 838b9f1b585526fcb13183e23817cb417c694d76 Mon Sep 17 00:00:00 2001 From: ApoloApps Date: Wed, 9 Sep 2026 16:45:36 +0200 Subject: [PATCH 12/12] Fix flaky tests related to dom scrolling driving compose scrolling by removing hardcodes values (which do not generalize) and some awaits related to compose scolling not having finished, causing tests to not assert correctly because the changes are not being awaited --- .../compose/ui/platform/a11y/A11yScrollTest.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt index 5701c70e0a17d..a6f6945220909 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/a11y/A11yScrollTest.kt @@ -135,8 +135,11 @@ class A11yScrollTest : OnCanvasTests { "scrollHeight=${element.scrollHeight}, clientHeight=${element.clientHeight}" ) - // Content: 10 items x 50dp in a 100dp viewport => 500dp total content extent - val expectedContentHeightCssPx = 500 + // Content: 10 items x 50dp in a 100dp viewport => ~500dp total content extent. + // Compose rounds each item to whole physical pixels, so on fractional-density screens + // the reported extent differs from 500 (e.g. 504 css px at density 1.25). Derive the + // expected extent from the scroll state instead of hardcoding it. + val expectedContentHeightCssPx = scrollState.maxValue / density + element.clientHeight assertTrue( abs(element.scrollHeight - expectedContentHeightCssPx) <= 2, "Scrollable extent must match the content size reported by Compose, " + @@ -175,8 +178,10 @@ class A11yScrollTest : OnCanvasTests { element.scrollTop = 50.0 val expectedComposePx = (50f * density).toInt() + // Also await the settled state: scrolling again while the ScrollBy-initiated animation + // is still in flight would interrupt it, losing its remaining delta. awaitCondition("Compose scroll state must follow the DOM scroll offset") { - abs(scrollState.value - expectedComposePx) <= 1 + !scrollState.isScrollInProgress && abs(scrollState.value - expectedComposePx) <= 1 } // Scroll further: the second delta must be computed against the new offset (not doubled)