Repository navigation
[iOS] ScrollView: Complete every superseded or dropped scroll request's task - #37666
Conversation
… stack, bound by identity Three holes in the deferral machinery, all of the same species — a request ending without its task completing, or completing on the wrong stack: 1. Yielding the replay to a re-entrant ScrollToRequested subscriber abandoned the superseded caller's task (the TaskStatus.Running check in CheckTaskCompletionSource is dead — a TCS task is WaitingForActivation — so nothing ever cancels it): permanent hang. 2. The inline drop completion ran the caller's continuation from inside Element.SetParent, before OnPropertyChanged(nameof(Parent)) — caller code re-entering a half-propagated parent change. 3. A parked request displaced by any newer ScrollToAsync (two calls in a row, the ordinary case) orphaned the first caller's task: hang. One primitive fixes all three. CompleteWithoutScroll captures the completion source as a value and posts TrySetResult through the dispatcher: not on the mutation stack (fixes 2), bound to exactly the task whose request ended so a newer request swapping the field cannot be released in its place (the identity discipline from the earlier dropped-task fix), and applied at every point a request ends without its scroll — the drop paths, the yielded replay (fixes 1), and the supersede in OnScrollToRequested (fixes 3). The platform-callback completion path is untouched, so await ScrollToAsync timing when the scroll happens is unchanged. The contract comment now names all three exits (replayed, superseded, dropped) and each is pinned by tests proven to fail without its fix: the re-entrant replay strand, the inline-on-SetParent completion, the field-read-at-fire-time identity regression, and both supersede shapes. Verified: ScrollViewUnitTests 42/42 (10 consecutive runs), Issue36801 + Issue36801DeferredElement + ShellFlyoutHeaderScrollViewContent iOS UI suites 12/12 on the simulator (booted-simulator set untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Azure Pipelines: Successfully started running 1 pipeline(s). There may be pipelines that require an authorized user to comment /azp run to run. |
|
Hey there @@albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
|
Hey there @albyrock87! Thank you so much for your PR! Someone from the team will get assigned to your PR shortly and we'll get it reviewed. |
This comment has been minimized.
This comment has been minimized.
MauiBot
left a comment
There was a problem hiding this comment.
AI Review Summary
@albyrock87 — new AI review results are available based on commit
1500c23.
🗂️ Review Sessions — click to expand
🚦 Gate — Test Before & After Fix
Gate Result: ✅ PASSED
Platform: IOS · Base: inflight/current · Merge base: a2935e60
| Test | Without Fix (expect FAIL) | With Fix (expect PASS) |
|---|---|---|
🧪 ScrollViewUnitTests ScrollViewUnitTests |
✅ FAIL — 47s | ✅ PASS — 30s |
🔴 Without fix — 🧪 ScrollViewUnitTests: FAIL ✅ · 47s
Error-relevant lines (filtered from the build log):
at Microsoft.Maui.Controls.Core.UnitTests.ScrollViewUnitTests.ReentrantRequestFromTheReplayedEventSupersedesTheReplay() in /_/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs:line 943
at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
at Microsoft.Maui.Controls.Core.UnitTests.ScrollViewUnitTests.SupersedingAParkedRequestCompletesTheSupersededCallersTask() in /_/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs:line 1027
at Microsoft.Maui.Controls.Core.UnitTests.ScrollViewUnitTests.DropCompletionIsPostedNotRunOnTheMutationStack() in /_/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs:line 968
at Microsoft.Maui.Controls.Core.UnitTests.ScrollViewUnitTests.PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst() in /_/src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs:line 994
🟢 With fix — 🧪 ScrollViewUnitTests: PASS ✅ · 30s
(no coded error found; showing last 1200 chars)
rollPosition [< 1 ms]
Passed TestConstructor [< 1 ms]
Passed ElementRequestOnHiddenScrollViewWaitsAndScrollsOnceShown [1 ms]
Passed PreHandlerParkedOffsetRequestIsDroppedWhenTheViewLeavesTheTree [< 1 ms]
Passed TestBackToBackBiDirectionalScroll [1 ms]
Passed DeferredElementScrollCompletesWhenTheHandlerGoesAway [8 ms]
Passed ConsecutiveDropsEachCompleteTheirOwnTask [< 1 ms]
Passed DoubleLifecycleEndOnOneRequestCompletesItOnceAndLeavesLaterRequestsAlone [< 1 ms]
Passed DeferredElementScrollCompletesWhenContentArrangesToZero [< 1 ms]
Passed TestScrollWasNotFiredOnNeither [< 1 ms]
Passed NewerRequestSupersedesAGeometryParkedElementRequest [< 1 ms]
[xUnit.net 00:00:01.41] Finished: Microsoft.Maui.Controls.Core.UnitTests
Passed PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst [< 1 ms]
Passed DirectRequestSupersedesDeferredElementRequest [< 1 ms]
Passed TestScrollToNotAnimated [< 1 ms]
Passed TestChildDoubleSet [< 1 ms]
Passed ReparentingCancelsAPendingElementScroll [< 1 ms]
Passed ElementTargetsAccountForViewportAndContentCoordinateInsets [1 ms]
Test Run Successful.
Total tests: 42
Passed: 42
Total time: 1.8580 Seconds
📁 Fix files reverted (1 files)
src/Controls/src/Core/ScrollView/ScrollView.cs
📋 Pre-Flight — Context & Validation
PR #37666 Pre-Flight
Context
- Title:
[iOS] ScrollView: Complete every superseded or dropped scroll request's task - Base / head:
inflight/currentata2935e6065f6743c6f2d8957fe0a3b2ae8e26115→1500c23ae10d1ac96ce342d54644bbdd52a5622c - Issue lineage: Follow-up to #37409 and #37060 for #36801.
- Platform: iOS.
Problem
Deferred ScrollToAsync requests can finish without completing the task returned to their caller:
- A newer request can supersede a parked request and strand the older task.
- A re-entrant request raised while replaying a parked request can strand the replayed task.
- Lifecycle drops currently complete inline from the parent/handler mutation stack, allowing continuations to observe or mutate partially propagated state.
Any alternative must preserve latest-request-wins behavior, complete exactly the request that ended, avoid completing a newer request through a stale callback, and keep normal platform SendScrollFinished timing unchanged.
Existing PR Approach
The PR adds CompleteWithoutScroll(TaskCompletionSource<bool>), captures a specific completion source by identity, and dispatches TrySetResult(true). It calls that helper from lifecycle/orphan drops, re-entrant replay supersession, and ordinary parked-request supersession. The PR also removes reliance on the ineffective TaskStatus.Running cancellation check for these paths.
Diff
src/Controls/src/Core/ScrollView/ScrollView.cs(+53/-22): production fix and contract comments.src/Controls/tests/Core.UnitTests/ScrollViewUnitTests.cs(+100/-1): four failing assertions/scenarios covering supersession, re-entrant replay, posted completion, and completion-source identity.- No public API change.
- The baseline script identified only
src/Controls/src/Core/ScrollView/ScrollView.csas a revertable fix file. Candidate implementations must not edit any other tracked file.
Existing Evidence
Gate verification already passed and must not be rerun:
- Without the production fix,
ScrollViewUnitTestsfailed in four relevant cases. - With the production fix, all 42
ScrollViewUnitTestspassed. - Regression cross-reference found no recent labeled-fix overlap.
Bounded Validation
Run each command at most once for the initial implementation, with at most one focused correction and one retest:
- Primary unit test:
dotnet test src/Controls/tests/Core.UnitTests/Controls.Core.UnitTests.csproj --configuration Debug --logger "console;verbosity=normal" -p:TreatWarningsAsErrors=false --filter "ScrollViewUnitTests" - Mandatory iOS regressions named by the PR:
pwsh .github/scripts/BuildAndRunHostApp.ps1 -Platform ios -TestFilter "FullyQualifiedName~Issue36801|FullyQualifiedName~ShellFlyoutHeaderScrollViewContent"
Do not run the full unit or UI suite. A candidate passes only when both bounded commands pass; unavailable simulator/device infrastructure is Blocked, not Pass.
Applicable Constraints
- Follow
.github/instructions/performance-hotpaths.instructions.md: ScrollView is a hot path; avoid LINQ and per-request captured closures/allocations where practical. - The change is internal implementation only; do not add public API.
- Perform the try-fix skill's inline expert self-review; do not launch a reviewer agent.
- Restore solely with
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore.
🔬 Code Review — Deep Analysis
Expert Evaluation — PR #37666
Verdict: NEEDS_CHANGES
Confidence: Medium (platform-specific UI/task-lifecycle plumbing)
The submitted fix correctly binds dropped-request completion to the captured TaskCompletionSource, closes the parked-request and re-entrant replay hangs, and preserves normal platform callback completion. The approach still leaves an already-sent request stranded when a newer request supersedes it, however, because only _pendingScrollToRequested is captured for completion.
Actionable findings
- Major — already-sent requests can still hang.
OnScrollToRequestedonly captures the superseded completion when a request is parked. With a ready handler, two consecutive directScrollToAsynccalls replace_scrollCompletionSource; the first task has no remaining completion path. - Moderate — drop completion now depends on dispatcher availability and pumping.
Dispatcher.Dispatchcan throw when teardown has removed all dispatcher context, or leave the task pending if the queue is never drained.TaskCreationOptions.RunContinuationsAsynchronouslycan keep continuations off the mutation stack without making completion dispatcher-dependent. - Minor — the parked completion capture is stale under event re-entrancy. It is captured before
ScrollToRequestedsubscribers run but displaced afterward, temporarily violating the helper's identity invariant. - Moderate — tests omit the direct-send supersede shape and assert dispatcher-post mechanics. The regression suite should cover direct-send-to-direct-send supersession and assert continuation ordering rather than a specific queued-post implementation.
Consolidated reviewer improvement
Create the TaskCompletionSource<bool> with TaskCreationOptions.RunContinuationsAsynchronously, complete captured superseded/dropped sources directly with TrySetResult(true), and capture the prior completion for every new request rather than only parked requests. Update the focused unit tests to cover direct-send supersession and continuation ordering without depending on dispatcher queue counts.
The trusted Gate passed, but it does not cover the unresolved direct-send supersession scenario identified above.
🛠️ Try-Fix — Analysis & Comparison
PR #37666 — STEP 5a Alternative Fix Candidates
Candidate 1 — Retire-on-displacement ownership transfer
Model: claude-opus-5
Result: Blocked
Self-review: 0 findings
Approach
Treat _scrollCompletionSource as an ownership slot. When a request ends, RetireScrollCompletion() would take the source out of the field, leave the slot empty, append the retired source to a queue, and drain that queue through one cached dispatcher delegate. OnScrollToRequested would retire a parked request at the start of displacement; replay state flags would replace the PR's post-event ReferenceEquals probe.
Prior approach avoided
The PR captures a specific TaskCompletionSource<bool> at each terminal call site and dispatches a closure through CompleteWithoutScroll, detecting replay displacement with ReferenceEquals. This candidate instead makes displacement transfer ownership out of the shared field, so terminal identity is structural rather than reconstructed at each site.
Mechanism-level difference
Emptying the slot when a request ends means a newer request installs into an empty field, while the queued drain owns only retired sources. A late completion therefore cannot read or release the newer source. Lifecycle completion remains posted off the mutation stack, normal SendScrollFinished timing remains unchanged, and a cached drain delegate avoids a captured closure per retirement.
Intended diff
Only src/Controls/src/Core/ScrollView/ScrollView.cs would change:
- add a retired-completion queue and cached drain delegate;
- retire the active source on lifecycle/orphan drop;
- retire at the start of parked or replay displacement;
- track replay-in-progress/superseded state explicitly;
- leave the normal platform completion path unchanged.
Actual diff: empty. The candidate correctly made no tracked edits after baseline establishment failed. See try-fix/attempt-1/fix.diff.
Test results
- Primary
ScrollViewUnitTests: not run. - iOS
Issue36801,Issue36801DeferredElement, andShellFlyoutHeaderScrollViewContentregressions: not run.
Running them against the unchanged HEAD would have exercised the PR fix rather than this candidate and could not establish a candidate Pass.
Failure analysis
pwsh .github/scripts/EstablishBrokenBaseline.ps1 rejected 39 pre-existing tracked modifications under .github/scripts/**, .github/skills/**, and eng/scripts/**. It created no .github/.baseline-state.json, so there was no RevertedFiles allow-list and no script-restorable candidate edit was permitted. The skill forbids cleaning those harness-owned changes with checkout, restore, reset, or stash.
The attempt is therefore Blocked, not Fail: the mechanism was designed but never implemented or empirically tested.
Restore
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore returned Restored False: No baseline state found, the accepted no-state path because baseline failed before any edit. The source tree remained unchanged.
Detailed narrative: CustomAgentLogsTmp/PRState/37666/PRAgent/try-fix-1/content.md
Attempt artifacts: CustomAgentLogsTmp/PRState/37666/PRAgent/try-fix/attempt-1/
Candidate 2 — Per-request operation state machine
Model: gpt-5.6-sol
Result: Blocked
Self-review: 0 findings
Approach
Replace the ambient raw completion-source protocol with a private sealed ScrollRequestOperation. Each request operation would own immutable request arguments, its task completion source, an idempotent terminal state, and a cached instance completion callback. Pending and active fields would hold operation identities rather than separately mutable request and completion values.
Lifecycle/orphan drop and supersession would transition the displaced operation through EndWithoutScroll(Dispatcher). The pre-bound callback could complete only its owning operation after the Element mutation stack unwinds. Replay would retain and compare operation identity across event delivery; a re-entrant replacement would end only the replayed operation and suppress its stale send. Normal SendScrollFinished would complete the active operation inline.
Prior approaches avoided
The PR captures a raw TCS at terminal sites and posts a captured lambda, while candidate 1 removes the TCS from the shared slot and places it in a retirement queue drained by a cached delegate. Candidate 2 uses neither terminal-site TCS capture nor ownership transfer into retirement storage.
Mechanism-level difference
Request identity, data, task, and terminal state become one object invariant for the entire request lifetime. Displacement transitions that object while a newer operation independently becomes active. The cached callback is pre-bound to its operation and cannot complete a newer one; idempotent transitions handle duplicate terminal paths without a retired-source queue. Replay correctness follows from operation identity rather than TCS-slot reconstruction or replay flags.
Intended diff
Only src/Controls/src/Core/ScrollView/ScrollView.cs would change:
- add private
ScrollRequestOperationstate and completion behavior; - replace pending-args and active-TCS fields with operation fields;
- make
OnScrollToRequestedreturn the locally created operation task; - route drop, supersession, replay, and platform completion through operation transitions;
- preserve normal native callback timing and add no public API.
Actual diff: empty. The candidate made no tracked edits after the baseline failure. See try-fix/attempt-2/fix.diff.
Test results
- Primary
ScrollViewUnitTests: not run. - iOS
Issue36801,Issue36801DeferredElement, andShellFlyoutHeaderScrollViewContentregressions: not run.
Without an implementation, either command would only validate unchanged PR HEAD.
Failure analysis
The mandatory baseline command rejected the same 39 pre-existing harness-owned tracked modifications and produced no .github/.baseline-state.json. Without a RevertedFiles allow-list, editing ScrollView.cs would violate the try-fix boundary and could not be restored through the only permitted script.
The result is Blocked, not Fail: this distinct design remains unimplemented and empirically unvalidated.
Restore
pwsh .github/scripts/EstablishBrokenBaseline.ps1 -Restore returned Restored False: No baseline state found, the accepted no-state outcome because no edit occurred.
Detailed narrative: CustomAgentLogsTmp/PRState/37666/PRAgent/try-fix-2/content.md
Attempt artifacts: CustomAgentLogsTmp/PRState/37666/PRAgent/try-fix/attempt-2/
Aggregate Result
Two mechanism-level alternatives were bounded and recorded. Both were blocked before implementation by the same baseline precondition; neither has empirical test evidence, and neither should be treated as a passing replacement for the PR fix.
📝 PR Finalize — Recommended Title & Description
Assessment: ✏️ Recommend updating — the title and one description claim overstate the raw diff as covering every superseded request, while direct-sent supersession remains unhandled.
Recommended title
[iOS] ScrollView: Complete superseded parked, replayed, and dropped deferred requests
Recommended description
### 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 affected deferred-request paths.** `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 where these deferred requests end without their scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and superseding a parked request 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 for parked requests.
No new public API.
### Issues Fixed
Follow-up to #37409 / #37060 (fixes for #36801).
### Tests
Every submitted fix is pinned by a test **proven to fail with that fix reverted**:
- `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both parked 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)
🏁 Report — Final Recommendation
⚠️ Final Recommendation: REQUEST CHANGES
Winner: pr-plus-reviewer
The submitted PR fixes the proven parked, replay, and lifecycle-drop hangs, but the expert review found that a request already sent to the handler is still orphaned when a newer request replaces its completion source. The pr-plus-reviewer candidate closes that additional supersession path and prevents an event-reentrant outer request from reclaiming the newer request's state, while retaining the PR's validated behavior.
| Rank | Candidate | Implementation | Regression evidence | Assessment |
|---|---|---|---|---|
| 1 | pr-plus-reviewer |
Consolidated source-only reviewer patch | PASS — 42/42 ScrollViewUnitTests; PASS — 12/12 focused iOS tests |
Best validated behavior. Fixes the raw PR's direct-send hang and re-entrant ownership gap. |
| 2 | pr |
Submitted captured-TCS dispatcher fix | Trusted Gate PASS; submitted focused suites passed | Strong for parked/replayed/dropped requests, but retains a concrete direct-send supersession hang. |
| 3 | try-fix-2 |
Proposed per-request operation state machine | BLOCKED — no diff and no tests | Paper design gives request identity the clearest ownership model, but it is invasive and has no empirical evidence. |
| 4 | try-fix-1 |
Proposed retire-on-displacement queue | BLOCKED — no diff and no tests | Unimplemented, more stateful, and its described displacement trigger does not clearly cover direct-send-to-direct-send supersession. |
No candidate failed regression tests. The two implemented candidates rank above both blocked candidates because they have passing evidence; the blocked designs cannot displace validated code.
Expert findings reconciliation
- Already-sent supersession hang: fixed by
pr-plus-reviewer, which captures the prior completion source for every new request. - Event re-entrancy ownership: fixed by returning the invocation-local task and yielding shared state when a subscriber installs a newer request.
- Dispatcher availability / never-pumped queue: unresolved; the candidate retains the submitted mechanism to respect the production-only refinement boundary and existing test contract.
- Coverage gap: unresolved; no explicit direct-send supersession test was added outside the permitted production file.
The raw PR therefore should not be approved as submitted. Apply the persisted pr-plus-reviewer/reviewer.patch, then address or explicitly accept the remaining dispatcher and coverage risks. No second refinement or retest was performed.
📱 UI Tests — ScrollView
Detected UI test categories: ScrollView
✅ Deep UI tests — 173 passed, 0 failed, 1 skipped across 1 category on platform-pool agent (replaces in-process counts above).
🧪 UI Test Execution Results (deep, platform pool)
| Category | Tests | Snapshot diffs |
|---|---|---|
ScrollView |
173/174 (1 skipped) ✓ | — |
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs) |
🧭 Next Steps — reviewer patch required (pr-plus-reviewer)
The reviewer-enhanced candidate won, so the submitted PR still needs those changes.
Why: The pr-plus-reviewer candidate preserves the submitted fix while closing the expert review's concrete direct-send supersession hang and re-entrant ownership gap. It passed all 42 focused unit tests and all 12 required iOS regressions; both try-fix alternatives were unimplemented and blocked.
Apply PRAgent/pr-plus-reviewer/reviewer.patch from the CopilotLogs
artifact (or follow the report's Required submitted-PR change), push the update, and run
the review again.
This comment has been minimized.
This comment has been minimized.
|
Closing the loop on the review findings for anyone reading this later. Finding 1 (two consecutive direct |
…'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>
…'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>
…'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>
…'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>
…'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>
…'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>
…'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>
…'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>
…'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>
…'s task (#37666) ### Description of Change Follow-up to #37409 (itself a follow-up to #37060), closing three holes of the same species in the deferred element-scroll machinery: **a request that ends without its task completing, or whose completion runs on the wrong stack.** Two are permanent hangs of `await ScrollToAsync(...)`; one is reachable by entirely ordinary code. 1. **A parked request displaced by any newer `ScrollToAsync` orphaned the first caller's task** — two calls in a row (say, both from `OnAppearing` before the first layout) and the first `await` never returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and the `TaskStatus.Running` check in `CheckTaskCompletionSource` is dead code (a TCS-backed task reports `WaitingForActivation`, never `Running`, so the `TrySetCanceled` there is unreachable). This is the headline fix. 2. **Yielding the replay to a re-entrant `ScrollToRequested` subscriber abandoned the superseded caller's task** — same mechanism, narrower trigger (a legacy renderer issuing its own `ScrollToAsync` from inside the replayed event): permanent hang. 3. **The lifecycle-drop completion ran the caller's continuation from inside `Element.SetParent`**, before `OnPropertyChanged(nameof(Parent))` fires — caller code observing/mutating a half-propagated parent change. **One primitive fixes all three.** `CompleteWithoutScroll(completion)` captures the completion source **as a value** and posts `TrySetResult(true)` through the dispatcher: - **off the mutation stack** (fixes 3) — the continuation never runs inside `SetParent`/handler-change; - **bound by identity** (preserves the invariant from #37409's review rounds) — the post cannot read the field at fire time, so a newer request swapping `_scrollCompletionSource` in the window can never be released in the old request's place; - **applied at every point a request ends without its scroll** — the drop paths (detach, tree-removal, orphaned target), the yielded replay (fixes 2), and the supersede in `OnScrollToRequested` (fixes 1). The ordinary platform-callback completion (`SendScrollFinished` when a scroll actually happens) is untouched, so `await ScrollToAsync(...)` timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — no `await` in this API's history has thrown on supersede, and introducing `TaskCanceledException` would be a new public behavior. The contract comment now names all three exits from the park — replayed, superseded, dropped — so the stated contract and the code have no gap. No new public API. ### Issues Fixed Follow-up to #37409 / #37060 (fixes for #36801). ### Tests Every fix is pinned by a test **proven to fail with that fix reverted**: - `SupersedingAParkedRequestCompletesTheSupersededCallersTask` — both supersede shapes (direct send, and park-in-place) - `ReentrantRequestFromTheReplayedEventSupersedesTheReplay` — extended to assert the superseded caller is released - `DropCompletionIsPostedNotRunOnTheMutationStack` — dispatcher-capture harness: task pending after `Remove` returns, exactly one post queued, completes when it runs - `PostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst` — the identity property: a new request arriving before the post runs is untouched; the dropped task completes (this test fails against a field-reading post, the bug shape from #37409's review) Verified on the merged `inflight/current` base: `ScrollViewUnitTests` 42/42 (10 consecutive runs), `Issue36801` + `Issue36801DeferredElement` + `ShellFlyoutHeaderScrollViewContent` iOS UI suites 12/12 on the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Description of Change
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.ScrollToAsyncorphaned the first caller's task — two calls in a row (say, both fromOnAppearingbefore the first layout) and the firstawaitnever returns. Nothing else ever completes it: the superseding request creates a fresh completion source, and theTaskStatus.Runningcheck inCheckTaskCompletionSourceis dead code (a TCS-backed task reportsWaitingForActivation, neverRunning, so theTrySetCanceledthere is unreachable). This is the headline fix.ScrollToRequestedsubscriber abandoned the superseded caller's task — same mechanism, narrower trigger (a legacy renderer issuing its ownScrollToAsyncfrom inside the replayed event): permanent hang.Element.SetParent, beforeOnPropertyChanged(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 postsTrySetResult(true)through the dispatcher:SetParent/handler-change;_scrollCompletionSourcein the window can never be released in the old request's place;OnScrollToRequested(fixes 1).The ordinary platform-callback completion (
SendScrollFinishedwhen a scroll actually happens) is untouched, soawait ScrollToAsync(...)timing on the normal path is unchanged. Completing (rather than cancelling) a superseded task matches the existing drop semantics — noawaitin this API's history has thrown on supersede, and introducingTaskCanceledExceptionwould 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 releasedDropCompletionIsPostedNotRunOnTheMutationStack— dispatcher-capture harness: task pending afterRemovereturns, exactly one post queued, completes when it runsPostedDropCompletionReleasesOnlyTheDroppedTaskEvenIfANewRequestArrivesFirst— 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 [iOS] ScrollView: Complete element scroll requests inside collapsed branches #37409's review)Verified on the merged
inflight/currentbase:ScrollViewUnitTests42/42 (10 consecutive runs),Issue36801+Issue36801DeferredElement+ShellFlyoutHeaderScrollViewContentiOS UI suites 12/12 on the simulator.🤖 Generated with Claude Code