Skip to content

fix: Cancel click on touch long-press menu - #23596

Draft
MartinZikmund wants to merge 3 commits into
feature/breakingchangesfrom
dev/mazi/longpress
Draft

fix: Cancel click on touch long-press menu#23596
MartinZikmund wants to merge 3 commits into
feature/breakingchangesfrom
dev/mazi/longpress

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

On Skia targets, long-pressing a control (e.g. a Button) with a
ContextFlyout both opened the menu AND raised Click, instead of
behaving like a right-tap. WinUI suppresses the click because opening
the menu's popup steals the OS pointer capture (PointerCaptureLost);
Uno's in-canvas popups don't, so the pressed state was never cleared.

When a context menu is shown on a touch press-and-hold, release the
pressing element's pointer capture and raise an unhandled
PointerCaptureLost so the control clears its pressed state and does not
click on release.

fixes #22229

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01WQEkhvePgnJMns2KhVNUxr**GitHub Issue:** closes #

PR Type:

What changed? 🚀

PR Checklist ✅

@github-actions github-actions Bot added the platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform label Jun 29, 2026
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 6m 51s —— View job


PR Review

  • Gather context and understand the request
  • Read changed files
  • Review ContextMenuProcessor.cs changes
  • Review UIElement.Pointers.cs changes
  • Review runtime tests (Given_ContextRequested.Injection.cs)
  • Review WASM CSS/TS changes
  • Post inline review comments
  • Post summary

Overall: ✅ Correct fix, 2 minor style nits and 1 defensive robustness suggestion.

The approach correctly mirrors WinUI's behavior: when a context popup steals OS pointer capture, WinUI gets a PointerCaptureLost that clears the pressed state. Uno's in-canvas popups don't steal capture, so this PR explicitly releases it. The Handled = false reset before calling ReleasePointerCapture is the critical detail — without it, PointerCaptureLost is filtered by the class handler and the pressed state never clears.

Key correctness verifications:

  • PointerCapture.Any() returns _actives.Values.ToList() — a clone — so calling ReleasePointerCapture inside the foreach loop in ReleaseContextMenuHoldingPointerCapture does not mutate the collection being iterated. ✅
  • captureTarget is correctly the ExplicitTarget (the element that explicitly captured the pointer, e.g. the Button), so ReleasePointerCapture fires PointerCaptureLost on the right element. ✅
  • For draggable elements (500 ms timer path), _contextMenuOnHoldingPointerId is stored before the timer fires, and the capture is only released if the context menu was actually shown (args.Handled). If the user lifts the finger during the delay, the capture is already gone from _actives and the loop silently does nothing. ✅
  • The WASM user-select: none is correctly scoped to html, body (the app root) and re-enabled only on the hidden IME <input>, so text editing continues to work. ✅

Findings (3 inline comments posted):

  • (issue #22229) references in method XML doc bodies — AGENTS.md style rule: issue cross-references belong in commit messages / [GitHubWorkItem] annotations, not in source prose.
  • ReleaseContextMenuHoldingPointerCapture should return early when _contextMenuOnHoldingPointerId == 0 (the default) to make the precondition explicit.

PR template note: "## PR Type:" and "## What changed? 🚀" sections are left empty. The issue is correctly referenced via fixes #22229 in the commit message, but the PR body should complete the template sections.

Comment thread src/Uno.UI/UI/Xaml/UIElement.Pointers.cs Outdated
Comment thread src/Uno.UI/UI/Xaml/Internal/ContextMenuProcessor.cs
Comment thread src/Uno.UI/UI/Xaml/Internal/ContextMenuProcessor.cs Outdated
@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23596/wasm-skia-net9/index.html

@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 3m 51s —— View job


PR Review

  • Gather context and understand the request
  • Read changed files and diff
  • Check if previous review suggestions were addressed
  • Review ContextMenuProcessor.cs changes
  • Review UIElement.Pointers.cs changes
  • Review runtime tests
  • Review WASM CSS/TS changes
  • Post inline review comments
  • Post summary

Overall: ✅ Correct approach — 3 prior nits still open, 2 new style nits posted

The new WASM commit (8a550f83) is clean: user-select: none / -webkit-user-select: none / -webkit-touch-callout: none on html, body correctly blocks the browser's native long-press selection gesture before Uno's own contextmenu event fires, and the IME input correctly re-enables userSelect so text editing is unaffected. No issues in the CSS or TypeScript changes.

Carryover findings from the previous review (still unaddressed):

The 3 inline comments posted in the first review pass are still outstanding on the C# changes:

  1. ContextMenuProcessor.cs:114(issue #22229) in the XML doc body. AGENTS.md: issue cross-references belong in commit messages / [GitHubWorkItem], not source prose.
  2. UIElement.Pointers.cs:1656 — same (issue #22229) reference in the doc comment.
  3. ContextMenuProcessor.cs:116ReleaseContextMenuHoldingPointerCapture has no early-return guard for _contextMenuOnHoldingPointerId == 0. Harmless in practice (no real pointer uses ID 0), but an explicit guard would make the precondition self-documenting.

New findings (2 inline comments posted):

  1. Given_ContextRequested.Injection.cs:423(issue #22229) in the Assert.AreEqual message string. Same style rule; [GitHubWorkItem] on the method is the right place for the link.
  2. Given_ContextRequested.Injection.cs:479 — same in the second test's assertion.

All findings are style/robustness nits; none affect correctness. The core fix and the runtime tests are sound.

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23596/wasm-skia-net9/index.html

@unodevops

Copy link
Copy Markdown
Contributor

⚠️⚠️ The build 221960 has failed on Uno.UI - CI.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 4m 36s —— View job


PR Review #23596

  • Gather context and understand the request
  • Read changed files and diff
  • Check if previous review suggestions were addressed
  • Review ContextMenuProcessor.cs changes
  • Review UIElement.Pointers.cs changes
  • Review runtime tests
  • Review WASM CSS/TS changes
  • Post inline review comments (none needed — no new findings)
  • Post summary

Overall: ✅ All previous nits addressed — ready to ship

All 5 findings from prior review passes have been resolved:

  1. (issue #22229) removed from UIElement.Pointers.cs XML doc ✅
  2. (issue #22229) removed from ContextMenuProcessor.cs XML doc ✅
  3. Early-return guard for "no holding pointer recorded" now handled via uint? — cleaner than the suggested == 0 check ✅
  4. (issue #22229) removed from When_Touch_LongPress_Opens_ContextFlyout_Without_Click assertion ✅
  5. (issue #22229) removed from When_Touch_LongPress_ContextRequested_Handled_Does_Not_Click assertion ✅

Correctness checks (all pass):

  • _contextMenuOnHoldingPointerId is uint? — null means "no holding gesture recorded", which is correct since pointer ID 0 is valid on Android. The comment explains this. ✅
  • SetContextMenuOnHoldingPointer is called before ProcessContextRequestOnHoldingGesture in the holding event handler, so both the direct path and the timer path for draggable elements have the correct pointer ID. ✅
  • PointerCapture.Any() returns a clone, so calling ReleasePointerCaptureForContextMenu inside the foreach in ReleaseContextMenuHoldingPointerCapture does not mutate the iterated collection. ✅
  • ReleasePointerCaptureForContextMenu resets Handled = false on each capture target's last dispatched args before calling ReleasePointerCapture — this ensures the class handler fires for PointerCaptureLost and clears the pressed state. ✅
  • WASM user-select: none / -webkit-user-select: none / -webkit-touch-callout: none on html, body suppresses the browser's native long-press selection UI before Uno's contextmenu event fires; the IME input re-enables userSelect: "text" so text editing is unaffected. ✅
  • Both new runtime tests are async Task, use [GitHubWorkItem] for traceability, and have try/finally cleanup. The using var finger disposal guarantees release even if the flyout-opened assertion fails. ✅

CI: Triggered runs from the chore: Address review feedback push are still pending as of this review; no failures. The July 13 failure pre-dates the latest commit.

@MartinZikmund

Copy link
Copy Markdown
Member Author

Review comments addressed — baec68336893cc2780ac97061fe9db3f9bd07b02

All 5 unresolved threads are handled. Validation level: code inspection only — nothing was built, run, or tested locally. CI (Azure Pipelines) will verify.

Fixed (5)

file:line reviewer point what changed
ContextMenuProcessor.cs:122 _contextMenuOnHoldingPointerId is 0 when SetContextMenuOnHoldingPointer was never called; add an explicit early-return to make the invariant clear Fixed, but not with the suggested == 0 sentinel — see the note below. The field is now uint? and the method early-returns on is not { } holdingPointerId. Same clarity, no sentinel hazard.
ContextMenuProcessor.cs:116 drop (issue #22229) from the doc comment Removed.
UIElement.Pointers.cs:1656 drop (issue #22229) from the doc comment Removed.
Given_ContextRequested.Injection.cs:423 drop (issue #22229) from the assertion message Removed.
Given_ContextRequested.Injection.cs:479 drop (issue #22229) from the assertion message Removed.

Also swept one unflagged occurrence of the same nit in the prose comment at Given_ContextRequested.Injection.cs:449. The [GitHubWorkItem] attributes remain as the traceability link, per the reviewer's own reasoning.

Note on the == 0 guard — suggestion amended, not taken as written

The suggested guard assumed "no real pointer uses ID 0". That does not hold on Android, where PointerHelpers.GetPointerId composes the id as:

return ((uint)nativeEvent.GetPointerId(pointerIndex) & _pointerIdsCount) << _pointerIdsShift | (uint)nativeEvent.DeviceId;

The first finger has GetPointerId(0) == 0, so a device reporting DeviceId == 0 produces a legitimate pointer id of 0. Treating 0 as "unset" would silently skip the capture release there and reintroduce the very bug this PR fixes — on the exact platform (touch) it targets.

So the reviewer's intent (make "no holding pointer recorded" explicit rather than relying on a scan that happens to find nothing) is implemented, with a nullable field that carries the distinction unambiguously:

// Nullable as 0 is a valid pointer id (e.g. first finger on an Android device with deviceId 0).
private uint? _contextMenuOnHoldingPointerId;

private void ReleaseContextMenuHoldingPointerCapture()
{
    // No holding pointer was recorded (e.g. ContextRequested raised without a holding gesture).
    if (_contextMenuOnHoldingPointerId is not { } holdingPointerId)
    {
        return;
    }
    ...
}

This path is reachable: RaiseContextRequestedEvent(..., isTouchInput: true) can be called without a preceding SetContextMenuOnHoldingPointer (the setter has exactly one caller, the HoldingState.Started branch in UIElement.Pointers.cs), so the guard is worth having.

No change needed (0) / Needs @MartinZikmund input (0)

Nothing escalated; no threads left open.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 2m 56s —— View job


PR Review #23596

  • Gather context and understand the request
  • Read changed files and diff
  • Check if previous review suggestions were addressed
  • Review ContextMenuProcessor.cs changes
  • Review UIElement.Pointers.cs changes
  • Review runtime tests
  • Review WASM CSS/TS changes
  • Post inline review comments (none — no new findings)
  • Post summary

Overall: ✅ All previous findings resolved — ready to ship

All 5 findings from the prior review passes have been addressed in 888c9204:

Finding Resolution
(issue #22229) in UIElement.Pointers.cs XML doc Removed ✅
(issue #22229) in ContextMenuProcessor.cs XML doc Removed ✅
No early-return when no holding pointer recorded uint? null-check early-return — cleaner than == 0 sentinel since pointer id 0 is valid on Android ✅
(issue #22229) in When_Touch_LongPress_Opens_ContextFlyout_Without_Click assertion Removed ✅
(issue #22229) in When_Touch_LongPress_ContextRequested_Handled_Does_Not_Click assertion Removed ✅

Correctness checks (all pass):

  • SetContextMenuOnHoldingPointer is called at line 488 of UIElement.Pointers.cs before ProcessContextRequestOnHoldingGesture, so both the direct path and the timer path (OnContextRequestOnHoldingTimeout) have the correct pointer id set when RaiseContextRequestedEvent fires. ✅
  • Timer path (contextMenuProcessor?.RaiseContextRequestedEvent(…, isTouchInput: true)) is safe: the timer only fires for draggable elements, and SetContextMenuOnHoldingPointer has already run in the HoldingState.Started branch that created the timer. ✅
  • _contextMenuOnHoldingPointerId is not { } holdingPointerId early-return correctly handles the case where RaiseContextRequestedEvent(isTouchInput: true) is called without a preceding holding gesture (harmless: the method returns immediately). ✅
  • PointerCapture.Any() returns a clone, so calling ReleasePointerCaptureForContextMenu inside the foreach in ReleaseContextMenuHoldingPointerCapture does not mutate the iterated collection. ✅
  • ReleasePointerCaptureForContextMenu resets Handled = false on each explicit-capture target's LastDispatched args before calling ReleasePointerCapture — this ensures the class handler fires for PointerCaptureLost and clears the pressed state. ✅
  • [RunsOnUIThread] is on the class (Given_ContextRequested_Injection); new tests inherit it. The file is #if __SKIA__ guarded. using var finger disposes (releases) the touch point even if an assertion inside try throws. ✅
  • WASM: user-select: none / -webkit-user-select: none / -webkit-touch-callout: none on html, body blocks the browser's native long-press selection UI before Uno's contextmenu event fires; (input.style as any).webkitUserSelect = "text" on the hidden IME input correctly re-enables selection so text editing is unaffected. ✅
  • The only other caller of RaiseContextRequestedEvent passes isTouchInput: false (right-click path, line 60), so ReleaseContextMenuHoldingPointerCapture is never triggered from that path. ✅

No new findings. The PR is clean.

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23596/wasm-skia-net9/index.html

Comment on lines +130 to +137
foreach (var capture in captures)
{
if (capture.Pointer.PointerId == holdingPointerId
&& capture.ExplicitTarget is { } captureTarget)
{
captureTarget.ReleasePointerCaptureForContextMenu(capture.Pointer);
}
}
Comment on lines +1670 to +1676
foreach (var target in capture.GetTargets(PointerCaptureKind.Explicit))
{
if (target.LastDispatched is { } lastDispatched)
{
lastDispatched.Handled = false;
}
}
MartinZikmund and others added 3 commits August 22, 2026 13:38
On Skia targets, long-pressing a control (e.g. a Button) with a
ContextFlyout both opened the menu AND raised Click, instead of
behaving like a right-tap. WinUI suppresses the click because opening
the menu's popup steals the OS pointer capture (PointerCaptureLost);
Uno's in-canvas popups don't, so the pressed state was never cleared.

When a context menu is shown on a touch press-and-hold, release the
pressing element's pointer capture and raise an unhandled
PointerCaptureLost so the control clears its pressed state and does not
click on release.

fixes #22229

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQEkhvePgnJMns2KhVNUxr
On touch devices the browser starts a text-selection gesture on
long-press, firing a haptic buzz and selection/callout UI before the
cancelable 'contextmenu' event - so preventing the context menu does
not stop the buzz, and it conflicts with the app's own press-and-hold
gestures (e.g. opening a ContextFlyout).

Uno Skia draws all text and selection with Skia, so the browser
selection is never useful. Disable it (and the long-press callout) on
the app root via user-select/-webkit-touch-callout, and re-enable
selection on the hidden IME input so text editing keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQEkhvePgnJMns2KhVNUxr
Guard the holding-pointer capture release with an explicit "not recorded"
check (nullable id, as 0 is a valid pointer id) and drop issue-number
cross-references from doc comments and assertion messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKBgQoAmRmaFoXXZr93iLB
@MartinZikmund
MartinZikmund changed the base branch from master to feature/breakingchanges August 22, 2026 14:40
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 3m 42s —— View job


PR Review #23596

  • Gather context and understand the request
  • Read changed files and diff
  • Review ContextMenuProcessor.cs changes
  • Review UIElement.Pointers.cs changes
  • Review runtime tests
  • Review WASM CSS/TS changes
  • Post inline review comments (none — no new blocking findings)
  • Post summary

Overall: ✅ Ready to ship — no new findings

All 5 findings from prior review passes remain resolved in the current HEAD (888c920). No blocking issues in this pass.

Correctness checks (all pass):

  • _contextMenuOnHoldingPointerId is uint?; ReleaseContextMenuHoldingPointerCapture early-returns when null, correctly handling the "ContextRequested raised without a preceding holding gesture" path. ✅
  • SetContextMenuOnHoldingPointer (line 478, UIElement.Pointers.cs) is called before ProcessContextRequestOnHoldingGesture in the HoldingState.Started branch, so the pointer ID is always set before either the direct or timer-delayed RaiseContextRequestedEvent(isTouchInput: true) fires. ✅
  • PointerCapture.Any(out var captures) returns a clone, so calling ReleasePointerCaptureForContextMenu inside the foreach in ReleaseContextMenuHoldingPointerCapture does not mutate the iterated collection. ✅
  • ReleasePointerCaptureForContextMenu resets Handled = false on each explicit-capture target's LastDispatched args before calling ReleasePointerCapture; this ensures the class handler fires for PointerCaptureLost and clears the pressed state. ✅
  • If the user lifts their finger before the timer fires (draggable-element path), the capture is already gone from the live state; the cloned captures list either is empty or won't contain the holding pointer ID, so the loop silently does nothing — correct. ✅
  • WASM: user-select: none / -webkit-user-select: none / -webkit-touch-callout: none on html, body blocks the browser's native long-press selection UI before Uno's contextmenu event fires. input.style.userSelect = "text" and (input.style as any).webkitUserSelect = "text" on the hidden IME input correctly re-enable text selection so editing is unaffected. ✅
  • Tests: [RunsOnUIThread] is at class level so both new methods inherit it. File is #if __SKIA__ guarded. using var finger disposes the touch point even when an assertion throws. Both tests use [GitHubWorkItem] for traceability. ✅

Optional (non-blocking) observations:

  • _contextMenuOnHoldingPointerId is never cleared to null after ReleaseContextMenuHoldingPointerCapture completes. It's always overwritten by the next SetContextMenuOnHoldingPointer call before the next use, so there is no correctness bug. Adding _contextMenuOnHoldingPointerId = null; at the end of ReleaseContextMenuHoldingPointerCapture would make the post-release state self-documenting and guard against hypothetical future callers — but this is purely defensive.
  • The github-code-quality bot suggested using .Where(...) on the foreach loops in ContextMenuProcessor.cs:130 and UIElement.Pointers.cs:1638. These are style-only suggestions with no correctness or performance impact; take or leave at author's discretion.

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23596/wasm-skia-net9/index.html

@nventive-devops

Copy link
Copy Markdown
Contributor

The build 229489 found UI Test snapshots differences: skia-linux-screenshots: 73, skia-windows-screenshots: 181

Details
  • skia-linux-screenshots: 73 changed over 2366

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • ClipboardTests.png-dark
    • ClipboardTests.png
    • ExpanderColorValidationPage.png-dark
    • ExpanderColorValidationPage.png
    • ButtonClippingTestsControl.png-dark
    • ButtonClippingTestsControl.png
    • Attributed_text_FontSize_Changing.png
    • Buttons.png
    • ContentControl_Inheritance.png
    • Examples.png
    • ImageBrush_Stretch.png
    • ImageIconPage.png-dark
    • ImageIconPage.png
    • DropDownButtonPage.png-dark
    • Focus_FocusVisual_Properties.png-dark
    • DynamicFont.png
    • Image_AnimatedWebP.png-dark
    • CalendarView_Theming.png-dark
    • DisplayInformation.png-dark
    • DisplayInformation.png
  • skia-windows-screenshots: 181 changed over 2368

    🚨🚨 Comparison Details (first 20) 🚨🚨
    • Battery.png-dark
    • ButtonClippingTestsControl.png-dark
    • Examples.png
    • GetFocus.png-dark
    • GetFocus.png
    • ChatBox.png-dark
    • DisplayInformation.png-dark
    • Dopes.png-dark
    • ElementLevelTheme.png-dark
    • ContentPresenter_Padding.png-dark
    • ContentPresenter_Padding.png
    • FocusManagerTest.png-dark
    • FocusManagerTest.png
    • Contacts_Pick.png-dark
    • Contacts_Pick.png
    • ContentPresenter_NativeEmbedding_Android_FillType.png
    • EmailManagerTests.png-dark
    • EmailManagerTests.png
    • GeolocatorPositionChanged.png-dark
    • GeolocatorPositionChanged.png

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[WASM Skia] Long press clicks before opening context menu

3 participants