Skip to content

[Android] CollectionView: Defer collection changes during native layout - #39495

Draft
jfversluis wants to merge 3 commits into
mainfrom
jfversluis-scroll-performance-fix
Draft

jfversluis wants to merge 3 commits into
mainfrom
jfversluis-scroll-performance-fix

Conversation

@jfversluis

@jfversluis jfversluis commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description of Change

Binding a CollectionView item can synchronously raise a collection change. In the reported reproduction, TimePicker binding triggers sorting via ObservableCollection.Move while RecyclerView is computing layout. Mutating the adapter snapshot and issuing its native notification at that point throws IllegalStateException.

Add an adapter-owned, layout-aware FIFO dispatcher shared by observable snapshots, grouped roots, and grouped children. Defer the snapshot mutation and corresponding notification together until native layout finishes; normal main-thread changes remain synchronous. Posting only the native notification is insufficient because RecyclerView would still read an already-mutated snapshot during layout.

Preserve unaffected grouped child sources during incremental root changes so pending child notifications survive. Retain the gate through temporary EmptyView swaps, drop disposed-source work, and prevent native drag reorder from overtaking queued changes. Snapshot non-IList observables too; events without usable indices capture an event-time reset instead of absorbing later queued mutations. Correct multi-item move ordering and affected-range counts. No public API additions.

Merged current main without rewriting history to incorporate #37954. The resolution preserves upstream grouped EmptyView first/last-empty-group transitions while retaining incremental child tracking. Both models reviewed this integration, and an additional native-layout regression test covers it.

Runtime correctness: both actions crash in the unchanged reporter application with UseWorkaround=false on MAUI 10.0.110 and the original source baseline. After the merge, Edit (1x) and Edit 3x fast both pass, the application stays alive, and native row order is D, B, C, A, E. Tested on an Android 15/API35 ARM64 emulator; the reporter's Android 17 environment was not available. Exact Controls DLL payloads were verified inside all A/B APKs.

Validation:

dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj -p:ArtifactsDir=/Volumes/NieuwVolume/maui-39084-portable/ -p:PublicApiType=Validate --filter 'FullyQualifiedName~MarshalingObservableCollectionTests' --nologo -v quiet

15 passed after merging main, covering existing marshaling behavior, multi-item moves, and event-time resets for indexless changes followed by indexed changes.

env EmbedAssembliesIntoApk=true pwsh -NoProfile .github/skills/run-device-tests/scripts/Run-DeviceTests.ps1 -Project Controls -Platform android -Configuration Debug -BuildOnly -IncludeClasses Microsoft.Maui.DeviceTests.CollectionViewCollectionChangesTests -OutputDirectory /Volumes/NieuwVolume/maui-39084-runtime/device-tests-embedded

Initial filtered build passed. Subsequent exact-binary A/B APKs were repackaged using:

DOTNET_CLI_USE_MSBUILD_SERVER=0 dotnet build src/Controls/tests/DeviceTests/Controls.DeviceTests.csproj -f net10.0-android -c Debug --no-restore --disable-build-servers -m:1 -p:BuildProjectReferences=false -p:EmbedAssembliesIntoApk=true -p:TreatWarningsAsErrors=false -p:CustomAfterMicrosoftCommonTargets=/Volumes/NieuwVolume/maui-39084-runtime/device-test-controls-override.targets -p:Maui39084ControlsAssembly=/Volumes/NieuwVolume/maui-39084-merged-final/Microsoft.Maui.Controls.dll -p:PublicApiType=Validate -v minimal --nologo

Merged build passed, with an existing unrelated SwipeView analyzer warning. The session-local target substitutes the preserved Controls DLL; embedded payloads were verified before execution.

adb -s emulator-5580 shell am instrument --user 0 -e results-file-name testResults-issue39084-merged.xml -w com.microsoft.maui.controls.devicetests

19 passed, 0 failed after merging main. Before the integration test was added, the same 18-test suite against the payload-verified original source baseline produced 17 failures, chiefly native layout exceptions; normal synchronous behavior passed. Cases include binding-triggered mutation, FIFO mixed changes, grouped roots/children and bulk ranges, non-list observables, EmptyView transitions, reorder, and disposal. Explicit user 0 bypasses a local XHarness invocation problem (Invalid userId -2) that otherwise prevents tests from running.

dotnet build src/Controls/src/Core/Controls.Core.csproj -f net10.0-android36.0 -c Debug -p:ArtifactsDir=/Volumes/NieuwVolume/maui-39084-build/ -p:IncludeIosTargetFrameworks=false -p:IncludeMacCatalystTargetFrameworks=false -p:PublicApiType=Validate --no-restore --nologo -v quiet

Merged build passed: zero errors, three Gradle Java-8 obsolescence warnings. Original scoped repository formatting verification exited 0 (workspace reference warnings); conflict-resolution whitespace verification also exited 0. Task-owned changes pass git diff origin/main --check; unrelated whitespace already on main was not modified.

Runtime performance: no per-bind/per-scroll queue checks were added. Indexed normal-path notifications avoid a redundant dispatch closure; bulk grouped updates use range operations instead of repeated shifts.

Earlier short studies were inconclusive due to host noise and remain preserved. After merging main, the 400-move/1,200-scroll protocol cleared scrolling but left move timing inconclusive. A separately predefined higher-duration move protocol changes only MovesPerPhase=200 to 2000, keeping scrolling at 1,200 calls. It retains all 8 counterbalanced pairs (16 launches, each with 3 warmups and 7 measured rounds), with 2,000 fixed-height rows, 4,000 moves and 1,200 native scroll calls per round. Baseline and merged variants use identical harness and frozen current-main Core/Essentials/Graphics dependencies. No rounds were excluded or optional stopping used. Analysis uses paired launch medians, not correlated rounds as independent samples.

Metric Paired geometric mean change One-sided 95% upper bound
4,000 synchronous moves -6.93% -0.77%
1,200 synchronous scroll calls -1.47% +0.11%
Entire frame/scroll loop -0.11% +0.25%

These satisfy the predefined +5% non-regression screen for this longer workload. Managed move allocations fell 392 → 232 bytes/move (-40.82%), or 1,568,000 → 928,000 bytes per 4,000-move round. Managed scroll allocations increased 0.91%, within the screen. Across both variants there were 448,000 measured moves, 134,400 measured scroll calls, and 13,888 passing rendered-order checkpoints. All launches completed without fatal/layout exceptions. The earlier 400-move study remains inconclusive rather than being replaced or presented as passing.

All session-local harnesses, raw results, host observations, hashes, paired analyses, and earlier studies are preserved on the external volume. Final post-merge reports are perf-results-merged-long8/REPORT.md and perf-results-merged-moves8/REPORT.md under /Volumes/NieuwVolume/maui-39084-runtime/; both protocols completed, with the longer-move protocol meeting the unchanged screen for all timed metrics.

Limitations: Debug APKs, API35 emulator, simple nongrouped fixed-height rows. This does not establish physical-device, Release/AOT, grouped-template, native-memory, or frame-tail/jank performance. Final merged Controls SHA-256: 351cf969c5d1720fae1282e944a7b7fa39d95515e12379afad310c4aa18ae425.

Independent multi-model design/review findings were addressed before final runtime verification, including after the main merge. No other open PR mentioning this issue was found when initially publishing. Kept in draft pending CI and reporter validation.

Issues Fixed

Fixes #39084

Queue adapter snapshot mutations and notifications together while RecyclerView computes layout, preserving FIFO ordering and synchronous changes outside layout. Preserve grouped child sources and handle non-list observables, range changes, temporary EmptyView swaps, disposal, and native reordering.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4b474211-673f-4256-919f-ecdce2fa6611
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 39495

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 39495"

@jfversluis

This comment has been minimized.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@jfversluis

This comment has been minimized.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

@github-actions github-actions Bot added area-controls-collectionview CollectionView, CarouselView, IndicatorView platform/android labels Oct 20, 2026
@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Oct 20, 2026
Retain incremental group source tracking while incorporating the upstream first/last empty-group notifications. Add real-layout coverage for root and child EmptyView transitions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4b474211-673f-4256-919f-ecdce2fa6611
@jfversluis

This comment has been minimized.

@azure-pipelines

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

Avoid binding the callback during handler setup before its RecyclerView capture is assigned. Populate the initially empty source after initialization and keep all explicit layout and snapshot assertions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4b474211-673f-4256-919f-ecdce2fa6611
@jfversluis

This comment has been minimized.

@azure-pipelines

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@jfversluis

Copy link
Copy Markdown
Member Author

CI follow-up for head b657c7ec is complete. No change-related failure was identified in the completed results.

  • Device tests 1642213: all 8 jobs succeeded. The build-correlated Android, iOS, MacCatalyst and Windows Helix work-item sets were checked for hidden failures; all work items finished with exit code 0. The 19 new Android regression cases passed on both Mono and CoreCLR, with 1,029 passing Controls tests on each runtime.
  • Main build 1642208: the remaining macOS Debug error compiles Android sample sources for MacCatalyst; no source-selection/build configuration is changed by this PR. PackageArtifacts are available and their archive/package contents and CI-merge metadata were verified.
  • UI tests 1642212: the four failing legs were investigated using execution logs, actual test results and screenshots. Entry failed after a successful update when the keyboard obscured the lower controls; Windows Shell2 differs in focus-status rendering (TextColor also fails on the target branch); iOS SafeAreaEdges has the same landscape assertion failure on the target; Mac CollectionView7 renders the expected groups/items with a cosmetic screenshot difference and does not execute the modified collection source. These are unrelated to this fix; not all have identical target-branch failures.

Coverage gap: the macOS Cells/CheckBox/ContextActions/CustomRenderers/DatePicker/Dispatcher/DisplayAlert/DisplayPrompt/DragAndDrop shard hit its 180-minute job limit during Install .NET, before its tests ran (setup log). This is not a passing test result. The macOS ScrollView tests completed successfully.

The reporter has been invited to try the draft artifacts. This PR remains draft; CI is not entirely green. Review and reporter-feedback monitoring continues.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@kubaflo

This comment has been minimized.

@github-actions

This comment has been minimized.

@jfversluis

Copy link
Copy Markdown
Member Author

Follow-up to the automated test report above: two listed failures recovered in the same-head category retries. VerifyEntryHorizontalEndTextAlignmentPosition failed at 15:15:58 UTC and passed at 15:30:23; ValidateKeyboard_None_Landscape_Grid failed at 15:27:02 and passed at 15:44:39. This shows retry recovery, not proof of identical target environments.

The requested snapshot comparison was performed during the earlier investigation: Windows CharacterSpacing differs in the displayed FocusStatus: Focused value versus the blank baseline; the requested character spacing is applied. Mac GroupedCollectionViewItems renders both groups and all four expected items, with differences on header glyph/divider/window edges. Its fixture populates before binding and does not mutate afterward; the Apple item-source implementation does not construct the modified MarshalingObservableCollection. These observations are causal evidence, not claims of exact target-branch matches.

For the remaining Android password test, the actual failure hierarchy has the requested selection 3–8 applied to the focused password Entry. The keyboard is open and the lower cursor inputs are no longer visible. The test uses the conditional hide helper rather than the focus-aware helper (test/helpers); navigation and the update itself succeeded. No Entry change is proposed here.

The report correctly retains limitations: no runtime-matched baseline proof for the safe-area failures, no evaluated-compile-input proof for the macOS Debug sample build failure, and no test results for the shard canceled during SDK installation. Those are not being represented as green coverage. The earlier CI follow-up records the successful device coverage and the setup-timeout gap.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

2 similar comments
@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

1 similar comment
@github-actions

Copy link
Copy Markdown
Contributor

/review gate was not started: another review or Gate is already running for this PR.

This command is not queued. Submit it again after the current run finishes.

@github-actions github-actions Bot added s/agent-review-in-progress AI review is currently running for this PR and removed s/agent-review-in-progress AI review is currently running for this PR labels Oct 21, 2026
@kubaflo

This comment has been minimized.

@azure-pipelines

This comment has been minimized.

@kubaflo

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

Tests Failure Analysis

@jfversluis — test-failure analysis for commit b657c7e.

Scope CI failures Commit b657c7e


🧪 CI Analysis — click to expand
📊 maui-pr

No failures found.


🧪 maui-pr-devicetests

No failures found.


🧪 maui-pr-uitests

🧭 Follow-up — actions and refresh

Next action: Restore omitted UI results/logs; inspect initial Android setup errors and unresolved snapshots before attributing them.

Maintainers: comment /review tests to refresh this report.

This branch was previously deployed

1 inactive (outdated) deployment
copilot-pat-pool — 215ba274 Deployed Oct 20, 2026 by jfversluis via conclusion #2412
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-controls-collectionview CollectionView, CarouselView, IndicatorView platform/android s/agent-review-in-progress AI review is currently running for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] [Debug] {"Cannot call this method while RecyclerView is computing a layout or scrolling crc645d80431ce5f73f11.MauiRecyclerView}

2 participants