Skip to content

[iOS] ScrollView: Clamp ScrollTo requests against AdjustedContentInset - #37060

Merged
kubaflo merged 11 commits into
dotnet:inflight/currentfrom
albyrock87:fix-36801-scrollto-adjustedinsets
Aug 10, 2026
Merged

kubaflo merged 11 commits into
dotnet:inflight/currentfrom
albyrock87:fix-36801-scrollto-adjustedinsets

Conversation

@albyrock87

@albyrock87 albyrock87 commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Description of Change

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.

Warning

Breaking behavioral change (iOS/MacCatalyst) — release-notes material: 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.)

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.

  • 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

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>
@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 20:12 — with GitHub Actions Inactive
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

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

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

Or

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

@albyrock87
albyrock87 temporarily deployed to copilot-pat-pool August 3, 2026 20:12 — with GitHub Actions Inactive
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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.

@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 5, 2026
@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-fix-pr-picked AI could not beat the PR fix - PR is the best among all candidates s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels Aug 5, 2026

@MauiBot MauiBot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

⚠️ Warning — Safe Area and Window Insets: adding 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 is C + 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

⚠️ Warning — iOS Platform / Regression Prevention: 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

⚠️ Warning — Public API Surface / Regression Prevention: this redefines the observable values of the public 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.

  1. Staleness: UIScrollView.Scrolled fires on contentOffset changes. When AdjustedContentInset changes while the view is scrolled (not at rest) UIKit leaves contentOffset alone, so no event fires and the published ScrollX/ScrollY stay wrong by the inset delta until the user scrolls again. The new HostApp page itself performs exactly such a mutation (Issue36801.cs:77 sets ContentInset from Loaded). Consider republishing from MauiScrollView.AdjustedContentInsetDidChange() (MauiScrollView.cs:147).
  2. No coverage: EvaluateOffset in the new HostApp page asserts native ContentOffset.Y only — it never reads _scrollView.ScrollY. Nothing in this PR (or in ScrollViewTests.iOS.cs / ScrollViewHandlerTests.iOS.cs) asserts that ScrollY == 0 at rest with a non-zero AdjustedContentInset, which is the actual contract being introduced. A Controls device test would pin this cheaply.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 AI-Generated Review (multi-model)

⚠️ Warning — Regression Prevention and Test Coverage: the oracle is a copy of the implementation, so the test cannot detect a wrong formula.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

@MauiBot

This comment has been minimized.

@MauiBot MauiBot removed the s/agent-review-in-progress AI review is currently running for this PR label Aug 5, 2026
albyrock87 and others added 2 commits August 5, 2026 21:58
…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>
@kubaflo

This comment has been minimized.

@github-actions github-actions Bot added the s/agent-review-in-progress AI review is currently running for this PR label Aug 5, 2026
@albyrock87

Copy link
Copy Markdown
Contributor Author

Latest AI review round fully dispositioned in 154984e — three findings adopted, three rebutted with mechanisms in the inline replies:

Adopted

  • Element requests are never parked where a collapsed (IsVisible=false) branch can't retry them — they dispatch immediately and the task completes (two new regression tests).
  • The arrange branch and SafeAreaBakedIntoContent now share one UIKitCompensatesForSafeArea predicate, eliminating the mirrored-condition fragility.
  • The PR description's behavioral note is promoted to an explicit Breaking behavioral change warning flagged as release-notes material.

Not adopted (inline replies have the details)

  • Capping targets to UIKit's ContentSize-based range: SetContentOffset doesn't clamp programmatic offsets (so the described hang can't occur), and the cap would reintroduce the Automatic-mode shortfall fixed in the previous round.
  • Reverting the drain-on-detach: the old "replay on attach" contract never completed the caller's task (completion-source reset), and parking across detach resurrects ghost scrolls.
  • Gating on the target element's geometry: no ScrollView-level callback fires when only a descendant arranges, so that park would be unretryable — a guaranteed hang in exchange for a theoretical misalignment.

Verified locally: ScrollViewUnitTests 25/25, full Issue36801 + deferred + Shell flyout iOS UI suites 12/12.

@kubaflo the new commit will need a fresh /azp run when you get a chance — thanks for the approval!

kubaflo pushed a commit that referenced this pull request Aug 12, 2026
#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>
@albyrock87

Copy link
Copy Markdown
Contributor Author

Heads-up for anyone following the review threads here: the final round's commit (154984e516 — the collapsed-branch completion fix and the shared safe-area predicate) was pushed a few hours after this PR was merged, so it never landed. It now lives in follow-up #37409, which also repairs the Issue36801 inset-mode expectations that were crossed by the #35533 landscape-notch revert (#36580) — without that repair the merged suite fails on the current inflight/current.

kubaflo added a commit that referenced this pull request Aug 19, 2026
…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>
kubaflo pushed a commit that referenced this pull request Aug 20, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Aug 27, 2026
#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>
kubaflo added a commit that referenced this pull request Aug 27, 2026
…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>
kubaflo pushed a commit that referenced this pull request Aug 27, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Sep 2, 2026
#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>
kubaflo added a commit that referenced this pull request Sep 2, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 2, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Sep 3, 2026
#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>
kubaflo pushed a commit that referenced this pull request Sep 3, 2026
#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>
kubaflo added a commit that referenced this pull request Sep 3, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 3, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Sep 4, 2026
#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>
kubaflo pushed a commit that referenced this pull request Sep 6, 2026
#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>
kubaflo added a commit that referenced this pull request Sep 6, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 6, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
#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>
kubaflo added a commit that referenced this pull request Sep 7, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
…'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>
kubaflo pushed a commit that referenced this pull request Sep 7, 2026
#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>
kubaflo pushed a commit that referenced this pull request Sep 8, 2026
#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>
kubaflo added a commit that referenced this pull request Sep 8, 2026
…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>
kubaflo pushed a commit that referenced this pull request Sep 8, 2026
…'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>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-controls-scrollview ScrollView community ✨ Community Contribution platform/ios s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-gate-passed AI verified tests catch the bug (fail without fix, pass with fix) s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: ScrollView.ScrollToAsync clamps without AdjustedContentInset — programmatic scrolls under-shoot by the safe-area amount

3 participants