Repository navigation
Conversation
MapRequestScrollTo clamped programmatic scrolls to [0, ContentSize - Frame], ignoring the UIScrollView's AdjustedContentInset. On inset scroll views (the standard .NET 10 edge-to-edge configuration) scroll-to-end stopped short by the adjusted-inset sum and scroll-to-zero could not restore the natural rest position (-adjustedInset.Top). Translate cross-platform content coordinates into native offset space, clamp against the inset-aware range, and apply the reverse translation when reporting ScrollX/ScrollY so requests round-trip. With zero insets the math degenerates to the previous behavior. Fixes dotnet#36801 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 37060Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 37060" |
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
Hey there @@albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
Hey there @albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 7 findings
See inline comments for details.
| var maxScrollVertical = Math.Max(minScrollVertical, uiScrollView.ContentSize.Height + adjustedInset.Bottom - bounds.Height); | ||
|
|
||
| var targetHorizontal = Math.Clamp(request.HorizontalOffset - (double)adjustedInset.Left, minScrollHorizontal, maxScrollHorizontal); | ||
| var targetVertical = Math.Clamp(request.VerticalOffset - (double)adjustedInset.Top, minScrollVertical, maxScrollVertical); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
❌ Error — Logic and Correctness / Regression Prevention: subtracting adjustedInset.Top here regresses ScrollToAsync(element, ScrollToPosition.End | Center | MakeVisible-downwards).
Controls/ScrollView.ConvertRequestMode → GetScrollPositionForElement (src/Controls/src/Core/ScrollView/ScrollView.cs:86-94) computes the End target as y = elementY - Height + element.Height, where Height is VisualElement.Height — the full frame height, not the inset-reduced visible viewport. That value then arrives here as request.VerticalOffset.
With ContentInsetAdjustmentBehavior.Automatic (the default for SafeAreaEdges.Default, see MauiScrollView.cs:249) and adjTop = 96, adjBottom = 34, bounds.Height = H:
- native target =
elementY + elemH - H - 96 - visible bottom edge in content coords =
target + H - adjBottom=elementY + elemH - 96 - 34 - → the element bottom sits 130pt below the visible bottom edge (was 34pt before this PR).
For a typical 30-44pt row that means the element the caller asked to scroll to is now entirely outside the visible region, whereas before it was only clipped by the home-indicator inset. ScrollToPosition.Start genuinely improves, so the origin shift is right — but the viewport length used by GetScrollPositionForElement must be corrected in the same change (or the End/Center/MakeVisible path compensated here) and covered by a test. As written the PR trades one broken direction for another with no coverage for ScrollToAsync(element, position) at all.
There was a problem hiding this comment.
Fixed in 347ee65. The regression was real: with the new coordinate mapping, GetScrollPositionForElement computed End/Center targets against the full frame height while the effective viewport is smaller by the insets. It now computes against the effective viewport (Height/Width minus AdjustedContentInset on iOS/Catalyst, unchanged elsewhere), so ScrollToAsync(element, End) lands the element's bottom edge exactly at the bottom of the unobscured viewport — for all insets, not just the pre-PR adjustedInset.Bottom clipping. Covered by the new ScrollToElementEndLandsInsideVisibleViewport UI test, whose oracle is geometric (probe frame vs. viewport bottom in window coordinates), verified passing on the iOS simulator.
| var minScrollHorizontal = -(double)adjustedInset.Left; | ||
| var minScrollVertical = -(double)adjustedInset.Top; | ||
| var maxScrollHorizontal = Math.Max(minScrollHorizontal, uiScrollView.ContentSize.Width + adjustedInset.Right - bounds.Width); | ||
| var maxScrollVertical = Math.Max(minScrollVertical, uiScrollView.ContentSize.Height + adjustedInset.Bottom - bounds.Height); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
adjustedInset.Bottom double-counts the bottom inset when SafeAreaEdges is Container / All / SoftInput.
For those regions MauiScrollView.UpdateContentInsetAdjustmentBehavior selects Always/Never (MauiScrollView.cs:251-254), and MauiScrollView.CrossPlatformArrange then already pads the reported content size:
else if (ContentInsetAdjustmentBehavior != UIScrollViewContentInsetAdjustmentBehavior.Automatic)
{
width += _safeArea.HorizontalThickness; // MauiScrollView.cs:500-504
height += _safeArea.VerticalThickness;
}For Container (→ Always) _safeArea is SystemAdjustedContentInset (MauiScrollView.cs:390-393) and AdjustedContentInset is non-zero, so with content of arranged height C:
ContentSize.Height = C + safeTop + safeBottom- new max =
C + safeTop + 2*safeBottom - bounds.Height, while the offset that puts the last content pixel flush with the visible bottom isC + safeBottom - bounds.Height.
So ScrollToAsync(0, ContentSize.Height, ...) on a SafeAreaEdges.Container ScrollView now stops with safeTop + safeBottom of blank space under the last item instead of safeTop. Please verify this mode on device and add a device/UI test for SafeAreaEdges.Container — the added test only covers the Automatic configuration, where the new formula is correct.
There was a problem hiding this comment.
Fixed in 347ee65. When ContentInsetAdjustmentBehavior is Always, the clamp now excludes the system-contributed inset (AdjustedContentInset - ContentInset, i.e. exactly the amount CrossPlatformArrange also baked into ContentSize) from the reachable maximum, so scroll-to-end stops at the content instead of safeTop + safeBottom into the padding. Computed from public UIScrollView properties — no MauiScrollView changes. Never mode is unaffected (its padding intentionally substitutes for the inset), and Automatic keeps the plain inset-aware formula, which the UI tests verify on-simulator against the content platform view's actual frame.
| // is (-adjustedInset.Left, -adjustedInset.Top) and the native maximum extends past | ||
| // ContentSize - Bounds by the trailing insets. Translate the request into native offset | ||
| // space and clamp against the inset-aware range (issue #36801). | ||
| var adjustedInset = uiScrollView.AdjustedContentInset; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
AdjustedContentInset can still be stale on the deferred PendingScrollToRequest path, so the fix may not apply to the scenario fixed by #35395.
The guard above only defers on ContentSize == CGSize.Empty. The drain happens in MauiScrollView.LayoutSubviews → ProcessPendingScrollRequest() (MauiScrollView.cs:347-350), which runs immediately after ContentSize = contentSize (line 337) and before base.LayoutSubviews() (line 353). Under ContentInsetAdjustmentBehavior.Automatic, UIKit derives adjustedContentInset from whether the view is scrollable (ContentSize vs Bounds) — this repo documents exactly that at MauiScrollView.cs:384-388 and 469-482, and the class overrides AdjustedContentInsetDidChange() precisely because the update arrives as a separate callback.
Net effect: ScrollView.ScrollToAsync(...) invoked from Page.OnAppearing (the path added by #35395) can compute with adjustedInset == UIEdgeInsets.Zero and land on the pre-fix offset, with nothing re-running the request afterwards. Please confirm on device and add coverage for the OnAppearing/first-layout path — the added UI test only taps a button long after layout has settled.
There was a problem hiding this comment.
Addressed in 347ee65, with an interesting root cause: the deferred path was broken before ever reaching the inset math. OnHandlerChangedCore replayed the pending request through OnScrollToRequested, which calls CheckTaskCompletionSource() — resetting the completion source and orphaning the task the original caller was awaiting, so a pre-layout ScrollToAsync never completed at all. The replay now invokes the handler directly. Coverage added: the HostApp page issues a ScrollToAsync from the constructor (before handler/layout exist, the #35395 shape) and the UI test asserts it settles on the inset-aware maximum; the page re-evaluates on a converge loop, so a late AdjustedContentInset settle would surface as a persistent failure. Verified passing on the iOS simulator — at drain time (LayoutSubviews, after ContentSize assignment) the insets were already final, so no re-clamp machinery was added.
| // rest offset is (-adjustedInset.Left, -adjustedInset.Top), which maps to (0,0) | ||
| // cross-platform so ScrollToAsync(ScrollX, ScrollY, ...) round-trips (issue #36801). | ||
| var adjustedInset = platformView.AdjustedContentInset; | ||
| VirtualView.HorizontalOffset = platformView.ContentOffset.X + adjustedInset.Left; |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
ScrollView.ScrollX/ScrollY bindable properties and ScrolledEventArgs on iOS, but the translation is applied only inside the Scrolled callback and nothing added here is covered by a test.
- Staleness:
UIScrollView.Scrolledfires oncontentOffsetchanges. WhenAdjustedContentInsetchanges while the view is scrolled (not at rest) UIKit leavescontentOffsetalone, so no event fires and the publishedScrollX/ScrollYstay wrong by the inset delta until the user scrolls again. The new HostApp page itself performs exactly such a mutation (Issue36801.cs:77setsContentInsetfromLoaded). Consider republishing fromMauiScrollView.AdjustedContentInsetDidChange()(MauiScrollView.cs:147). - No coverage:
EvaluateOffsetin the new HostApp page asserts nativeContentOffset.Yonly — it never reads_scrollView.ScrollY. Nothing in this PR (or inScrollViewTests.iOS.cs/ScrollViewHandlerTests.iOS.cs) asserts thatScrollY == 0at rest with a non-zeroAdjustedContentInset, which is the actual contract being introduced. A Controls device test would pin this cheaply.
There was a problem hiding this comment.
Partially addressed in 347ee65. (2) is covered: the HostApp oracle now also asserts the public contract — after each scroll (top, end, and the deferred pre-layout one) ScrollView.ScrollY must equal the native offset shifted by AdjustedContentInset.Top (0 at rest) — verified passing on the iOS simulator. (1) — republishing from AdjustedContentInsetDidChange when insets change while scrolled and at rest — is consciously deferred to keep this fix minimal: it needs a platform-view→handler hook, the staleness self-corrects on the next scroll event, and it predates this PR in spirit (main published raw native offsets that were wrong by the inset at all times, not just after an inset change). Happy to follow up separately if maintainers want it.
| } | ||
|
|
||
| double expected = expectEnd | ||
| ? nativeScrollView.ContentSize.Height + adjustedInset.Bottom - nativeScrollView.Bounds.Height |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
expected here is character-for-character maxScrollVertical from ScrollViewHandler.iOS.cs:135 (ContentSize.Height + adjustedInset.Bottom - Bounds.Height), and line 106 mirrors minScrollVertical (-adjustedInset.Top). The test therefore asserts the production code against itself: it goes green for any formula the handler happens to use, including the SafeAreaEdges.Container over-scroll noted on ScrollViewHandler.iOS.cs:135.
There is already an independent, user-visible oracle in the page that is never used — ProbeLabel ("BOTTOM PROBE", line 45-50) is created with an AutomationId but no assertion in TestCases.Shared.Tests/Tests/Issues/Issue36801.cs ever queries it. Asserting App.WaitForElement("ProbeLabel") after scroll-to-end (and that it is not displayed before) would prove the content is actually reachable rather than that the arithmetic matches itself.
There was a problem hiding this comment.
Fixed in 347ee65. The oracle no longer mirrors the implementation: the end expectation is measured from the content platform view's actual frame (contentView.Frame.Bottom + adjustedInset.Bottom - Bounds.Height), the element-End expectation from the probe's frame in window coordinates, and ScrollY is asserted against the cross-platform contract. The previously-unused ProbeLabel is now exercised too: after scroll-to-end and element-End, the Appium side asserts the probe rect is fully inside the page rect — pre-fix, the undershoot left the probe entirely below the fold, so this discriminates independently of any arithmetic.
| { | ||
| _endResultLabel.Text = "EndPending"; | ||
| await _scrollView.ScrollToAsync(0, _scrollView.ContentSize.Height, animated: false); | ||
| await Task.Delay(100); |
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Suggestion — Regression Prevention and Test Coverage: the fixed Task.Delay(100) turns the 5s retry in the UI test into dead weight.
WaitForTextToBePresentInElement("EndResultLabel", "Success", timeout: 5s) (HelperExtensions.cs:1052) re-polls the label, but the label is written exactly once, 100ms after the tap. If the offset has not settled at that instant the page records a permanent Fail (end): ... and the remaining ~4.9s of polling can never recover — the test fails deterministically on a slow/loaded CI device rather than retrying.
ScrollToAsync(..., animated: false) maps to Instant == true, so ScrollFinished() is already raised synchronously after SetContentOffset and the await alone guarantees the offset was applied. Either drop the delay, or re-evaluate on a timer/loop so the label converges to Success and the wait helper's retry actually does something.
There was a problem hiding this comment.
Fixed in 347ee65 — went with the converge option: the fixed Task.Delay(100) is gone, and each result label is now written by a loop that re-evaluates every 250ms (up to 5s) until the oracle reports success. The WaitForTextToBePresentInElement retry window is therefore meaningful again: a slow CI device converges to Success instead of freezing a permanently-failed first sample.
| @@ -0,0 +1,35 @@ | |||
| #if IOS // Validates iOS-specific UIScrollView AdjustedContentInset math with iOS-only instrumentation in the HostApp page | |||
There was a problem hiding this comment.
🔍 AI-Generated Review (multi-model)
💡 Suggestion — Platform-Specific Code Scoping: #if IOS leaves MacCatalyst — which ships the changed code — untested, and the HostApp fallback would report a false pass if the guard were widened.
ScrollViewHandler.iOS.cs compiles for both iOS and MacCatalyst, but this project defines MACCATALYST (not IOS) for the MacCatalyst head (Controls.TestCases.Shared.Tests.csproj:27-32), so the fix ships to MacCatalyst with zero coverage. Compounding it, the HostApp's #else branch returns "Success: not applicable on this platform" (TestCases.HostApp/Issues/Issue36801.cs:114), and WaitForTextToBePresentInElement does a case-insensitive Contains("Success") — so if anyone later relaxes this guard to #if IOS || MACCATALYST without also widening the HostApp guard, the test passes green while asserting nothing. Please either extend both guards to MacCatalyst, or make the non-iOS fallback return a string that cannot match "Success".
There was a problem hiding this comment.
Fixed in 347ee65. Both guards are now #if IOS || MACCATALYST (HostApp instrumentation and the UI test), so MacCatalyst — which ships this handler code — runs the tests. The false-pass trap is closed too: the non-applicable fallback now returns Skipped (...), which cannot match the case-insensitive Contains("Success").
This comment has been minimized.
This comment has been minimized.
…urface Review follow-up on dotnet#37060, keeping the change minimal relative to main: - ScrollView.GetScrollPositionForElement now computes End/Center/MakeVisible targets against the effective viewport (frame minus AdjustedContentInset on iOS/Catalyst) instead of the full frame. With the new coordinate mapping the old math landed the element adjustedInset.Top+Bottom past the visible region for downward scrolls (review error finding). - With ContentInsetAdjustmentBehavior.Always, MauiScrollView bakes the safe area into ContentSize while UIKit also applies it via AdjustedContentInset; MapRequestScrollTo now excludes the duplicated (system-contributed) amount from the reachable range, computed from public UIScrollView properties. - OnHandlerChangedCore replayed a pending scroll request through OnScrollToRequested, which resets the completion source and orphans the task the original caller is awaiting — a pre-layout ScrollToAsync never completed. The replay now invokes the handler directly. - Issue36801 HostApp page reworked per review: geometric oracles (content and probe platform-view frames, plus the ScrollX/ScrollY cross-platform contract) instead of mirroring the implementation formula, a converge loop instead of a fixed Task.Delay, MacCatalyst included in all guards, and the non-applicable fallback no longer matches "Success". New coverage: deferred pre-layout scroll, ScrollToAsync(element, End), and Appium-side probe visibility assertions. All Issue36801 UI tests verified passing on the iOS simulator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refactor only, no behavior change: MapRequestScrollTo shrinks back to defer/compute/apply, and the coordinate translation and inset-aware clamping move into a focused static helper. The Always-mode compensation now adjusts an effective content extent once instead of threading a duplicated-padding UIEdgeInsets through the max formulas, and the already-at-target check is a single CGPoint comparison. UI tests re-verified passing on the iOS simulator after the refactor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
|
Latest AI review round fully dispositioned in 154984e — three findings adopted, three rebutted with mechanisms in the inline replies: Adopted
Not adopted (inline replies have the details)
Verified locally: @kubaflo the new commit will need a fresh |
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Heads-up for anyone following the review threads here: the final round's commit ( |
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
#37060) On iOS, `MapRequestScrollTo` clamped programmatic scrolls to `[0, ContentSize - Frame]`, ignoring the `UIScrollView`'s `AdjustedContentInset`. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`, so: - `ScrollToAsync` to the end stopped **short by `adjustedInset.Top + adjustedInset.Bottom`**, leaving the last element under the home indicator while a finger fling lands it correctly above. - `ScrollToAsync(0, 0)` could not restore the natural rest position, which is `-adjustedInset.Top`, not `0`. This PR establishes one convention: **MAUI scroll offsets are content coordinates, where `(0,0)` places the top-left of the content at the top-left of the *visible* viewport.** That is what Android already does, and what `CollectionView` already reports on iOS (`ItemsViewDelegator` publishes `ContentOffset + ContentInset`) — `ScrollView` was the outlier reporting raw native offsets. 1. **Requests are translated into native offset space and clamped against the inset-aware range** (`request - adjustedInset`, clamped to `[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]`). With zero insets this degenerates to the previous math. `MauiScrollView.ScrollableContentSize` supplies the extent by **measuring the rect its own arrange placed the content into** (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), because `ContentSize` is not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is *also* applying through `AdjustedContentInset`, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers every `ContentInsetAdjustmentBehavior` mode — including `Automatic` with a zero system inset — with no mode branching. 2. **Reported offsets get the reverse translation**, so `ScrollX`/`ScrollY` are content coordinates and `ScrollToAsync(ScrollX, ScrollY, …)` round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (`IScrollOffsetReceiver`): `ScrollX`/`ScrollY` and their bindings stay current, but no `Scrolled` event is manufactured for a change no scroll produced. 3. **Element targets are computed against the effective viewport**, not the full frame — otherwise `ScrollToAsync(element, End)` lands the element `adjustedInset.Top + Bottom` past the visible region. The viewport comes from an internal Core contract (`IScrollViewportProvider`) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge. 4. **Deferred requests are fixed on two counts.** Replaying through `OnScrollToRequested` reset the completion source and orphaned the task the caller was awaiting, so a pre-handler `ScrollToAsync` never completed. And element-mode requests are no longer resolved at handler-attach, when `Width`/`Height` are `-1` and every content coordinate is `0`; they wait for geometry and a non-empty `ContentSize`, then resolve on the next dispatcher tick once the layout pass has finished arranging children. 5. **`ShellFlyoutLayoutManager` converts back to native coordinates.** Its header math works in native offsets (which rest at `-headerHeight`, since the header is carried in `ContentInset`), and its `ScrollView` branch consumed `ScrollY` verbatim — the adjacent `CollectionView` branch already does the equivalent conversion. No new public API; the new contracts and members are internal. > [!NOTE] > Behavioral change: on iOS/MacCatalyst `ScrollView.ScrollX`/`ScrollY` and `ScrolledEventArgs` now report content coordinates instead of raw `ContentOffset` — at rest they read `0` rather than `-adjustedInset.Top`. This aligns iOS with Android and with `CollectionView`'s existing iOS behavior. One consequence measured on both platforms: `ScrollToAsync(0, ContentSize.Height - Height)` does not reach the end when the scroll view consumes the safe area, because the reachable maximum is `contentHeight - visibleViewport` while that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactly `paddingTop + paddingBottom`), so this is cross-platform-consistent rather than iOS-specific; `ScrollToAsync(0, ContentSize.Height)` lands exactly at the end on both, and clamps safely. > > A second consequence: because the reported offset now includes `AdjustedContentInset.Top`, it changes when the **top** (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, or `SetHeaderContentInset` installing a Shell flyout header inset. With `adjTop` going 96 → 0 and `ContentOffset` untouched by UIKit, `ScrollY` moves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internal `IScrollOffsetReceiver` path, which updates `ScrollX`/`ScrollY` (and bindings) **without raising `Scrolled`** — scroll-driven consumers doing raw delta math (`SwipeView.OnParentScrolled`, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused *by* a hiding bar cannot feed back into that same logic. A real scroll still reports normally. > > Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves `ScrollY` at 200 and raises no event, but re-lays-out the content 156px higher (`CONTENT screenY -444 -> -600`). Because iOS keeps `contentInset` outside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raises `Scrolled` for an inset/padding-only change. The keyboard is **not** a trigger for this phantom shift: it changes the bottom inset, which does not enter `ScrollY = ContentOffset.Y + AdjustedContentInset.Top`. (It can still move `ScrollY` indirectly when the view sits near the bottom and UIKit re-pins `ContentOffset` into the shortened range — but there the content really moves, so that is an ordinary scroll notification.) Fixes #36801 `Issue36801` (iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay. - **Scroll to end / to top** — the content's last pixel must rest at the bottom of the unobscured viewport; the top must land on the natural rest position; `ScrollY` must match the content-coordinate contract. - **All three inset modes** — `Automatic`, `Never` and `Always` bake the safe area into the content differently, so each is run. `SafeAreaEdges` is set on the `ScrollView` (it does not propagate from the page) and the fixture asserts the *resolved* `ContentInsetAdjustmentBehavior`, so a drifting mode fails loudly instead of quietly testing another branch. - **`ScrollToAsync(element, End)`** — the probe's bottom edge must sit exactly at the visible viewport bottom. - **Deferred offset and deferred element requests** — both issued before the handler exists. - **Appium-side probe assertions** — the probe rect must be fully inside the page rect, which discriminates independently of any in-app arithmetic. `ShellFlyoutHeaderScrollViewContent` (iOS + MacCatalyst): a Shell with `FlyoutHeaderBehavior.Scroll` over a `ScrollView` flyout content — the path the existing `ShellFlyoutHeaderBehavior` test cannot reach, since the default flyout uses `ShellTableViewController` and raw native offsets. Unit tests (`ScrollViewUnitTests`) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updates `ScrollX`/`ScrollY` without raising `Scrolled` while a real scroll still does. Verified on the iOS simulator: **12/12 pass** (the full `Issue36801` inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reports `headerY=-106` on the unfixed code, and the deferred element test reported `actual=2523 expected=738` before the arrange-ordering fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…ranches (#37409) ### Description of Change Follow-up to #37060, carrying two pieces the merged PR is missing: **1. The final review-round commit that missed the merge.** #37060 was merged at `e3f386a811` a few hours before its last round landed, so the two findings adopted from the final AI review (and referenced in the inline replies there) never shipped: - **A parked element scroll request has one contract: it lives exactly as long as the task the caller is awaiting.** An element-mode request parked for arranged geometry (issued before the first layout, e.g. from `OnAppearing`) leaves the park in exactly two ways and no other: the arrange arrives and the request is replayed against real geometry (the task completes *with* the scroll), or the view's lifecycle ends — its handler goes away or it is removed from the tree — and the request is dropped (the task completes *without* a scroll). Nothing releases the task while a request is still parked, so a completed `await` can never scroll later; and nothing but a lifecycle end drops the request, so a view that is merely hidden (a collapsed branch, an unselected Shell tab) still scrolls to the element when it is eventually shown and arranged. A view that stays attached and is never arranged keeps the task pending — that is the contract, not a leak: *the task completes when the scroll happens or the view is torn down*. Removal from the tree is the newly-covered lifecycle end: `LayoutHandler.Remove` detaches only the platform view and does not disconnect the child's handler, so without it a removed ScrollView held a parked request nobody would ever retry. - **A dropped request completes its task inline, at the moment it is dropped**, binding the release to that request by construction — there is no window in which a newer `ScrollToAsync` could swap the completion source and be released in its place. This is the convention already in place (the shipped handler-detach drop and Core's `DisconnectHandler` both complete inline from their lifecycle hooks); the completion source construction is unchanged from before this PR, so ordinary `await ScrollToAsync(...)` timing is untouched on every platform. - **The geometry gate is per-target.** The ScrollView itself is a valid target (`ScrollToAsync(scrollView, …)`) and resolves to the origin needing only its own geometry; any other target sits inside the content and needs the content arranged too. A descendant target can be *orphaned* while parked (the content it hung off is removed, or replaced with content that no longer contains it): no arrange of this ScrollView can ever give it a position, so that is a terminal condition — the request is dropped and completed at the moment of orphaning, from the `Content` setter. Content replaced with a layout that still contains the target keeps waiting for that content's arrange. - **A request made from inside the replayed `ScrollToRequested` event wins over the replay.** A subscriber (as a compatibility renderer would be) that issues a new `ScrollToAsync` from inside the event previously had its request sent and then the stale replay sent on top of it, landing on the old target. The replay now yields when a request was made during the event, detected by the completion source having changed (every request creates a fresh one). The mechanism is UI-thread-affine, so re-entrant callbacks are its only race surface; every mutation site clears state before invoking any callback and re-checks after, and both re-entrant orderings (from the drop's inline completion, and from the replayed event) are pinned by tests that fail without the fix. - **Reparenting cancels a pending element scroll.** Removal from the tree includes the transient removal of a reparent: a request made against a tree position that no longer exists completes at the removal and is not carried into the new parent (a caller that moves a ScrollView with a pending scroll re-requests it there). The drop is deliberately not deferred to watch for a re-add, since a window between drop and completion is exactly what allows a newer request to be released in the old one's place. - **`SafeAreaBakedIntoContent` is captured at arrange time**, alongside the arranged rect, instead of being re-derived live from UIKit state: under `Automatic` the arrange that bakes the padding can push the content size past the bounds, at which point UIKit turns the adjusted inset non-zero while the content still carries the padding — a live read got that window wrong. The arrange branch and the `_safeArea` source selection share one predicate (`UIKitCompensatesForSafeArea`) where live evaluation is the correct semantics. **2. Repair of the `Issue36801` inset-mode expectations crossed by the landscape-notch revert.** The fixtures merged with #37060 asserted `SafeAreaEdges.Default` resolves to `Never`, matching the landscape-notch fix (#35533) present in `inflight/current` at the time. That fix has since been reverted (#36580), so `Default` resolves to `Automatic` again and the shipped resolved-mode sentinel currently fails the suite on this base. This restores the `Automatic` expectations in the shared tests and the HostApp oracle — the sentinel failing loudly on both transitions is it doing exactly what it was designed for (never silently testing a different clamp branch). No new public API; `WillArrange` and `UIKitCompensatesForSafeArea` are private. ### Issues Fixed Follow-up to #37060 (fixes for #36801). Review threads addressed: the [collapsed-branch finding](#37060 (comment)) and the [shared-predicate finding](#37060 (comment)). ### Tests Verified on the current `inflight/current` base on the iOS simulator: - `ScrollViewUnitTests`: 39/39 — including tests pinning each edge of the contract: a hidden view waits and scrolls once shown; a view leaving the tree drops the request, completes the task, and does not resurrect the request if re-attached; drop-then-new-request on both lifecycle ends completes only the dropped task and leaves the new one to scroll (reproduced failing on the deferred design); consecutive and doubled lifecycle ends; pre-handler parks in both modes leaving the tree; supersede while geometry-parked; content replacement with and without the target; content removal; the self-target on a content-less ScrollView; reparent cancellation; re-entrant requests from the drop continuation and from the replayed event - `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` UI suites: 12/12 — note these fail on the base without the expectation repair in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakub Florkowski <42434498+kubaflo@users.noreply.github.com>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Description of Change
On iOS,
MapRequestScrollToclamped programmatic scrolls to[0, ContentSize - Frame], ignoring theUIScrollView'sAdjustedContentInset. On inset scroll views (the standard .NET 10 edge-to-edge configuration where the scroll view consumes the safe area, or any scroll view with content insets) the valid native offset range is[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height], so:ScrollToAsyncto the end stopped short byadjustedInset.Top + adjustedInset.Bottom, leaving the last element under the home indicator while a finger fling lands it correctly above.ScrollToAsync(0, 0)could not restore the natural rest position, which is-adjustedInset.Top, not0.This PR establishes one convention: MAUI scroll offsets are content coordinates, where
(0,0)places the top-left of the content at the top-left of the visible viewport. That is what Android already does, and whatCollectionViewalready reports on iOS (ItemsViewDelegatorpublishesContentOffset + ContentInset) —ScrollViewwas the outlier reporting raw native offsets.request - adjustedInset, clamped to[-adjustedInset.Top, contentExtent + adjustedInset.Bottom - Bounds.Height]). With zero insets this degenerates to the previous math.MauiScrollView.ScrollableContentSizesupplies the extent by measuring the rect its own arrange placed the content into (recorded origin + arranged size, plus any trailing safe-area padding baked into the content coordinates), becauseContentSizeis not authoritative: depending on the inset mode the arrange pass pads it with a safe area UIKit is also applying throughAdjustedContentInset, inflates it to keep UIKit in scrollable mode, or omits a non-zero safe-area origin the content was arranged at. Measuring covers everyContentInsetAdjustmentBehaviormode — includingAutomaticwith a zero system inset — with no mode branching.ScrollX/ScrollYare content coordinates andScrollToAsync(ScrollX, ScrollY, …)round-trips. When the insets change without the offset moving (rotation, an auto-hiding bar), the values are refreshed through an internal silent path (IScrollOffsetReceiver):ScrollX/ScrollYand their bindings stay current, but noScrolledevent is manufactured for a change no scroll produced.ScrollToAsync(element, End)lands the elementadjustedInset.Top + Bottompast the visible region. The viewport comes from an internal Core contract (IScrollViewportProvider) implemented by the iOS handler, so the coordinate convention has a single owner and cross-platform code holds no platform inset knowledge.OnScrollToRequestedreset the completion source and orphaned the task the caller was awaiting, so a pre-handlerScrollToAsyncnever completed. And element-mode requests are no longer resolved at handler-attach, whenWidth/Heightare-1and every content coordinate is0; they wait for geometry and a non-emptyContentSize, then resolve on the next dispatcher tick once the layout pass has finished arranging children.ShellFlyoutLayoutManagerconverts back to native coordinates. Its header math works in native offsets (which rest at-headerHeight, since the header is carried inContentInset), and itsScrollViewbranch consumedScrollYverbatim — the adjacentCollectionViewbranch already does the equivalent conversion.No new public API; the new contracts and members are internal.
Warning
Breaking behavioral change (iOS/MacCatalyst) — release-notes material: on iOS/MacCatalyst
ScrollView.ScrollX/ScrollYandScrolledEventArgsnow report content coordinates instead of rawContentOffset— at rest they read0rather than-adjustedInset.Top. This aligns iOS with Android and withCollectionView's existing iOS behavior. One consequence measured on both platforms:ScrollToAsync(0, ContentSize.Height - Height)does not reach the end when the scroll view consumes the safe area, because the reachable maximum iscontentHeight - visibleViewportwhile that expression uses the full frame height. An Android probe confirms the identical shortfall there (short by exactlypaddingTop + paddingBottom), so this is cross-platform-consistent rather than iOS-specific;ScrollToAsync(0, ContentSize.Height)lands exactly at the end on both, and clamps safely.A second consequence: because the reported offset now includes
AdjustedContentInset.Top, it changes when the top (or left) inset changes even though the user did not scroll — a nav bar auto-hiding, a rotation, an iPad split-view resize, orSetHeaderContentInsetinstalling a Shell flyout header inset. WithadjTopgoing 96 → 0 andContentOffsetuntouched by UIKit,ScrollYmoves 296 → 200, which is accurate: 96 points that were behind the bar are now visible, so only 200 remain hidden above. That refresh flows through the internalIScrollOffsetReceiverpath, which updatesScrollX/ScrollY(and bindings) without raisingScrolled— scroll-driven consumers doing raw delta math (SwipeView.OnParentScrolled, hide-on-scroll logic) never observe a scroll that didn't happen, and an inset change caused by a hiding bar cannot feed back into that same logic. A real scroll still reports normally.Android reaches the same end state by the opposite route, measured on an emulator: changing the scroll view's padding 156 → 0 leaves
ScrollYat 200 and raises no event, but re-lays-out the content 156px higher (CONTENT screenY -444 -> -600). Because iOS keepscontentInsetoutside the content, nothing moves there and the number must change instead. Both platforms preserve "content hidden above the visible top", and neither raisesScrolledfor an inset/padding-only change. The keyboard is not a trigger for this phantom shift: it changes the bottom inset, which does not enterScrollY = ContentOffset.Y + AdjustedContentInset.Top. (It can still moveScrollYindirectly when the view sits near the bottom and UIKit re-pinsContentOffsetinto the shortened range — but there the content really moves, so that is an ordinary scroll notification.)Issues Fixed
Fixes #36801
Tests
Issue36801(iOS + MacCatalyst): a tall ScrollView given explicit content insets so the clamping math is exercised deterministically on any simulator. Oracles are geometric rather than restatements of the implementation — the end position is measured from the content platform view's actual frame, the element position from the probe's frame in window coordinates — and each result converges on a re-evaluation loop instead of a fixed delay.ScrollYmust match the content-coordinate contract.Automatic,NeverandAlwaysbake the safe area into the content differently, so each is run.SafeAreaEdgesis set on theScrollView(it does not propagate from the page) and the fixture asserts the resolvedContentInsetAdjustmentBehavior, so a drifting mode fails loudly instead of quietly testing another branch.ScrollToAsync(element, End)— the probe's bottom edge must sit exactly at the visible viewport bottom.ShellFlyoutHeaderScrollViewContent(iOS + MacCatalyst): a Shell withFlyoutHeaderBehavior.Scrollover aScrollViewflyout content — the path the existingShellFlyoutHeaderBehaviortest cannot reach, since the default flyout usesShellTableViewControllerand raw native offsets.Unit tests (
ScrollViewUnitTests) cover the deferred-request lifecycle, element targets under viewport/content-coordinate insets, and that an inset-only refresh updatesScrollX/ScrollYwithout raisingScrolledwhile a real scroll still does.Verified on the iOS simulator: 12/12 pass (the full
Issue36801inset-mode matrix, both deferred fixtures, and the Shell flyout header test). Each fix was also checked to fail without it — the Shell test reportsheaderY=-106on the unfixed code, and the deferred element test reportedactual=2523 expected=738before the arrange-ordering fix.🤖 Generated with Claude Code