Fix DataGrid committing an edit the moment it opens (#1288) - #1290
Alexandre Zollinger Chohfi (azchohfi) wants to merge 1 commit into
Conversation
📦 Build metricsArtifact sizes for Packages (compressed .nupkg)
Assemblies in Microsoft.UI.Reactor
Assemblies in Microsoft.UI.Reactor.Advanced
Assemblies in Microsoft.UI.Reactor.Devtools
✅ smaller / |
🧪 Merged coverageCoverage for
No coverage change beyond the noise floor. ✅ ✅ higher / |
7155b9a to
994ec3e
Compare
f451172 to
68ea1e1
Compare
68ea1e1 to
6cd460a
Compare
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Focus can be parked even when validation refuses a commit, leaving the active editor unfocused.
Review effort: Lite
Findings: None
What changed in this PR
Fixes a DataGrid focus race that could immediately commit newly opened editors.
Changes:
- Parks focus on the grid root before destructive edit transitions.
- Adds unit, selftest, and hardened E2E regression coverage.
- Documents the fix in the changelog.
| File | Description |
|---|---|
tests/Reactor.Tests/DataGridEditorFocusTests.cs |
Adds transition-order unit tests. |
tests/Reactor.AppTests/Tests/DataGridTests.cs |
Hardens E2E setup and diagnostics. |
tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs |
Registers new fixtures. |
tests/Reactor.AppTests.Host/SelfTest/Fixtures/DataGridEditFixtures.cs |
Adds live focus regression fixtures. |
src/Reactor.Advanced/Controls/DataGrid/DataGridState.cs |
Adds pre-transition hook calls. |
src/Reactor.Advanced/Controls/DataGrid/DataGridComponent.cs |
Implements grid-root focus parking. |
CHANGELOG.md |
Documents the fix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
cef95e7 to
3831377
Compare
…sition removes the focused element (#1288) Opening a DataGrid editor from inside the grid could commit it the moment it opened. Starting a row edit from the row's Edit button, or a commit-then-begin cell tap, removed the element holding keyboard focus (the Edit button, or the previous cell's editor) in the re-render that opened the new editor. XAML then moved focus to the next tab stop, which lies outside the grid because the grid is a single tab stop, and the grid's LostFocus blur-commit net raced the new editor's deferred focus request. Whenever its deferred check ran first, it found focus outside the grid and committed the edit just opened: the editor closed as it appeared, and onRowChanged fired with unchanged values. Two changes keep focus inside the grid: - DataGridState.BeforeEditTransition, wired to DataGridComponent.ParkFocusOnGridRoot, moves focus onto the grid root, a tab stop that survives renders, when focus is on one of the grid's descendants. BeginEdit and BeginRowEdit call it before they change state. The cell tap and the cross-row pointer press call it right after a commit that actually ended the in-flight edit. The re-render that removes the element is a later dispatcher tick, so the park lands first. A commit that validation refuses parks nothing, because that edit stays open and its editor keeps focus. Neither do a refused begin, row-mode Tab, or focus outside the grid. - While keyboard focus is on the root itself, the root claims an unhandled PointerReleased that can complete a primary-action press: a left mouse button, a touch contact, or a pen tip. WinUI's ScrollViewer takes focus on an unhandled release of a press whose IsLeftButtonPressed was true, unless it is not a tab stop and focus is already inside it (ScrollViewer::OnPointerReleased in microsoft-ui-xaml). The grid's data-area scroller cannot hold focus that is on the root, its ancestor, so the release reached the window's root ScrollViewer, a tab stop, which took focus out of the grid. A cross-row press parks from a deferred callback, so a release that arrived after that callback reopened the race. The PointerUpdateKind a touch or pen release reports is not documented, so only explicit right, middle, and X-button releases are left unclaimed. Measured with ci-stress (E2E, retries off) and a temporary probe that recorded every focus move: stock vs fixed, 60 iterations each: focus left the grid during the row Edit click 141/141 attempts vs 0/120 DoesNotSuppressNextCommit, first attempt failed 8/60 vs 0/60 WrapsToFirstEditorWithoutCommitting, first attempt 3/60 vs 0/60 ClickEditTabCommit failed 2/60 vs 1/60, the release path park only vs park + release handler, 30 iterations each: PressHeldOnAnotherRow_KeepsKeyboardFocusInGrid failed 30/30 vs passed 30/30 rest of the E2E suite 1 unrelated DragDrop flake vs green Tests: - Reactor.Tests: 13 unit tests pin when the hook runs, including commits that validation refuses in both edit modes, and a theory pins which release kinds the root claims. - Selftests: DataGrid_EditorFocusParkedFromEditButton and DataGrid_EditorFocusParkedOnCommitThenBegin read focus right after the transition, record every focus move until the new editor holds focus, check that a refused commit keeps focus in its editor, and pin IsFocusOnRoot. With the park no-oped, the focus-move checks failed on every run measured; the spurious commit itself appeared in 4 of 7 cell-mode runs. - E2E: PressHeldOnAnotherRow_KeepsKeyboardFocusInGrid holds a press so the park always runs before the release. TapWhileGridRootHasFocus_OpensTheTappedEditor guards that a release the root claims still lets the tapped cell open its editor. The row-edit setup helper, FreshRowEditGridWithFirstRowEditing, retries the whole setup with a fresh navigation, re-resolves the Edit button's volatile slug, names the shape of each failed attempt, and reports recovered attempts through TestContext. Mutation-checked: no-oping the park, parking before the commit, parking unconditionally after it, no-oping BeginRowEdit, removing the release handler, and narrowing its guard to LeftButtonReleased or widening it to every release each redden the tests aimed at them. Fixes #1288 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4959d8f7-94ca-4e1d-a617-c7bbf5e93b4e
3831377 to
e3c86aa
Compare

What was wrong
#1288 looked like an E2E flake, but it is a DataGrid bug: opening an editor from inside the grid could commit it the moment it opened. The editor closed as it appeared, and
onRowChangedfired with unchanged values.Mechanism
Starting a row edit from the row's Edit button, or a commit-then-begin cell tap, removes the element holding keyboard focus (the Edit button, or the previous cell's editor) in the re-render that opens the new editor. XAML then moves focus to the next tab stop, which is outside the grid, because the grid is a single tab stop. The grid's
LostFocusblur-commit net schedules a deferred check. That check races the new editor's deferred focus request, and whenever it runs first, it finds focus outside the grid and commits the edit that was just opened.The focus jump happens on every such click on stock code; only the commit depends on timing. That is why fast machines pass and loaded CI runners fail.
Fix
Park focus before the element is removed.
DataGridState.BeforeEditTransitionis wired toParkFocusOnGridRoot. When focus is on one of the grid's descendants, the park moves it onto the grid root, a tab stop that survives renders. It runs at two points:BeginEdit/BeginRowEdit, before the state changes;The render that removes the element is always a later dispatcher tick, so nothing that still holds focus is ever removed. The park never runs when:
Keep focus on the root through a pointer release. WinUI's
ScrollViewertakes focus on an unhandled left-button release unless it is not a tab stop and focus is already inside it (ScrollViewer::OnPointerReleased). The grid's data-area scroller keeps focus that sits in an editor, but it cannot keep focus on the root, which is its ancestor. The release therefore reached the window's root ScrollViewer, which is a tab stop (a temporary selftest diagnostic found it at the top of the visual tree withIsTabStop=True), and it took focus. A cross-row press parks focus from a deferred callback, so when the button came up after that callback, the steal happened while the tap's editor was opening. The root now claims an unhandled release that can complete a primary-action press (a left mouse button, a touch contact, or a pen tip) while keyboard focus is on the root itself, which is what the data-area scroller already does for focus inside it. ThePointerUpdateKinda touch or pen release reports is not documented, so only explicit right, middle, and X-button releases are left unclaimed. This path was found by the A/B below: 1 of 120 cross-row taps.Evidence
CI A/B,
ci-stress,target: e2e,e2e_retries: 0. A temporary focus probe recorded every focus move in every iteration, pass or fail. Real mouse input throughout.DoesNotSuppressNextCommit, first attempt failedWrapsToFirstEditorWithoutCommitting, first attempt failedClickEditTabCommitfailedPressHeldOnAnotherRow_KeepsKeyboardFocusInGridGrid > ScrollVieweron the release)DragDrop_TypedReorder_MovesCard(a known unrelated flake, also seen on stock)Earlier runs agree. Stock 36207044352 and 36211748809, against the fixed 36213516293 and 36214911933, showed:
DoesNotSuppressNextCommitfirst attempts: 21/120 failed on stock vs 0/120 fixed;ClickEditTabCommit: 9/120 vs 0/120.Deterministic, in-process. The selftests record every focus move across the transition (
FocusNeverLeftTheGrid):Does the release handler break click-to-edit? Claiming a release must not stop the cell's
Tapped, or clicking a cell would stop opening its editor whenever the grid root has focus. The recorded focus data says it does not. In all 180 recordedClickEditTabCommitruns, the first tap's release was taken and marked handled by the window's root ScrollViewer, and the tapped cell's editor still opened. The exact case, a tap on an editable cell whose release the grid root claims, is rare and invisible to the probe. The newTapWhileGridRootHasFocus_OpensTheTappedEditortest pins it deterministically, and it passes in CI.Tests
IsFocusOnRoot.PressHeldOnAnotherRow_KeepsKeyboardFocusInGridholds a press so the deferred park always runs before the release.TapWhileGridRootHasFocus_OpensTheTappedEditorchecks that a tap whose release the root claims still opens the tapped cell's editor. It guards the release handler; it also passes without the handler, so it is not a Flaky E2E: Interactive_DataGrid_RowEditTab_DoesNotSuppressNextCommit fails ~17% of runs ("no 'Save' button appeared") #1288 detector.BeginRowEditno-oped;LeftButtonReleased, or widened to every release.Locally: 14,289 unit tests, all 211 DataGrid selftests, and the Release builds of the changed projects are green.
Review
pr-reviewskill, 8 dimensions plus a GPT cross-check. Two medium findings, both fixed:795fdc422, had no findings.cef95e71b, so the SHAs above predate the squash.LeftButtonReleasedand said touch reportsOther. The Win32 and WinRT docs point the other way: a touch contact is the first button, andIsLeftButtonPresseddocuments it as the primary action. But the reported kind is not documented, so the guard now claims every release except an explicit release of another button, and a theory pins that.CI:
e3c86aa69. Its E2E job ran all 213 tests; the only 2 skips are the Spec051 devtools tests, which are inconclusive by design.Packaged Selftests (MSIX)failed, inLT_OnMountUnmountBalanced. That is the known flake [Flaky] LT_Unmounts_Exactly_30 / LT_NoLeak_MountsEqualUnmounts — real unmount-accounting deficit, not the #988 watchdog #994, in a lifecycle fixture this PR does not touch, and it passed on re-run.Follow-ups (not fixed here)
LostFocushandler is not re-wired whenElementPoolrecycles a grid root (KeyDownis; seeWireGridKeyDown), so stale handlers accumulate. They are harmless unless a grid is unmounted mid-edit.WinFormsInteropTestsflake (foreground_not_target).Fixes #1288