Skip to content

fix(android): retain pending offset until content range is ready - #118

Open
bytemain wants to merge 1 commit into
staging2from
mingqi/daf40beb-android-pending-range
Open

fix(android): retain pending offset until content range is ready#118
bytemain wants to merge 1 commit into
staging2from
mingqi/daf40beb-android-pending-range

Conversation

@bytemain

@bytemain bytemain commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

  • retain an Android KRRecyclerView contentOffset owner across every layout where the native content range is still too short
  • replace the old String-as-identity slot with an opaque owner object: identical payloads from separate callbacks are distinct generations, while a layout retry retains the same token
  • funnel every cancellation/replacement boundary through explicit owner helpers, including public smooth-scroll/SnapHelper commands, position SmoothScroller commands, Accessibility, generic motion, nested motion, and child-rectangle scrolling
  • keep identity-fenced Kuikly linear/spring animation frames on an explicit transport seam so an old frame cannot masquerade as a new direct scrollBy intent and erase a callback-installed owner
  • make a ready contentOffset invalidate an older custom animation before applying, so the newer intent remains the final physical winner after both participants quiesce
  • revoke custom-animation identity before synchronous ValueAnimator.cancel() callbacks, preventing cancellation from applying the old completion tail
  • publish a new animation identity before SETTLING callbacks and revalidate it afterward; a callback-installed immediate, RecyclerView, or newer custom transport cannot be overwritten or incorrectly cleaned up by the old start
  • record the transport that internalSetContentOffset() actually starts, rather than treating an animation request label as proof that a RecyclerView/custom scroller exists
  • finish no-op/abort/reentrant cancellation at IDLE with TYPE_NON_TOUCH nesting stopped and exactly one scrollEnd
  • close zero-distance and layout-suppressed RecyclerView replacements without killing a genuinely started callback-installed RecyclerView flinger
  • bind every real default RecyclerView contentOffset animation to its opaque ready-owner identity and stop that exact older ViewFlinger before any newer ready write
  • publish the newer ready owner before stopping the old ViewFlinger, revoke old identity before synchronous IDLE callbacks, and revalidate afterward so a callback-installed custom/RecyclerView transport survives
  • avoid RecyclerView stopScroll()'s post-callback second scroller stop; use the existing state seam that stops the old ViewFlinger before IDLE callbacks without killing a callback-installed replacement
  • close a TYPE_NON_TOUCH child connection inherited from an older custom animation after revoking the old active-RV identity but before publishing IDLE; a callback-installed custom owner can then start a fresh connection that outer cleanup never closes
  • stop the predecessor ViewFlinger without publishing state before closing that inherited connection; if the parent synchronously installs a newer transport from onStopNestedScroll, the old scroller is already inert
  • after the parent callback returns and NestedScrollingChildHelper finally clears its doomed parent pointer, reconnect only the still-current custom animation and preserve a callback-installed custom/RecyclerView transport instead of forcing it to IDLE
  • retain the latest range-deferred owner generation after slot consumption and revalidate it beside the ready owner, so an older outer write cannot consume a pending owner installed from the parent-stop callback window
  • cover RecyclerView 1.1's separate natural-terminal ordering: after ViewFlinger publishes IDLE callbacks and then clears its inherited TYPE_NON_TOUCH parent, reconnect only a callback-installed current custom animation; a paired callback-RecyclerView control remains unnested
  • when a natural custom-animation terminal closes TYPE_NON_TOUCH nesting and the parent stop callback installs a newer custom/RecyclerView transport, detect the changed opaque ready owner and skip the predecessor's stale stopScroll(); requested animations that started no transport remain IMMEDIATE and still receive terminal cleanup
  • defer the takeover's intermediate IDLE end and emit one terminal scrollEnd for the whole replacement chain
  • add 66 real Android/Robolectric production-behavior cases, including complete inherited transport histories, the four-kind parent-stop owner matrix, natural ViewFlinger and natural custom terminal controls, and precise nested start/stop ordering; no source-file/string correctness assertion

Physical product failure and confirmed lower-layer defect

KuiklyUI PR #117 correctly replays the still-current Compose target after a strict IME viewport shrink. Its valid Mobile carrier (55f54907cc4bb3a4d12a469b4471a86477c60122, VC1000159) nevertheless reproduced a real all-white message viewport on a physical HUAWEI SGT-AL10 / Android 16.

The exact window is a new Thread instance's first full-IME shrink while the focused origin is still loading and the target row is initially unplaced. Compose later reports healthy placement/draw geometry, but PixelCopy returns 87450 samples, nonWhiteCount=0, whitePermille=1000. The same process/launch supplied a positive control only 9.493 seconds earlier with nonWhiteCount=5488, whitePermille=937.

The production-path Android fixture confirms a necessary lower-layer defect in native range deferral:

  1. KNode replays the current target before the focused row has expanded the native content range.
  2. setContentOffset() cannot scroll yet and reinstalls the command as the pending native owner.
  3. tryApplyPendingSetContentOffset() used to clear the slot after that retry, erasing the command it had just reinstalled.
  4. When the focused row is placed and range grows, no native write remains, so the physical RecyclerView stays stale even though Compose placement/draw state is healthy.

This PR's lower-layer fix is capture → consume → retry. A still-short range reinstalls the captured owner; a ready-range write applies physically and leaves the slot empty.

This is not claimed as the sole root cause of the continuing product symptom. A later current-main carrier containing this exact (163c64128202babb152c80f599d048d08a48b08a, VC1000201) still produced a white Thread viewport on the same physical device. Its exact launch logs separately expose a Mobile focused-correction readiness race: originStatus=loaded can arrive before the focused anchor session publishes its identity/start generation, so the origin-correction effect returns and does not restart when readiness arrives. That Mobile gap is being closed independently under task Tencent-TDS#990. PR #118 therefore remains a necessary renderer transport hardening candidate, not a stand-alone physical-product PASS claim.

Opaque owner identity

The pending register is now PendingContentOffsetOwner, not a String pretending to be identity.

  • every direct programmatic contentOffset installs a fresh owner object, even if its serialized payload exactly equals the previous payload
  • a range-not-ready layout retry passes the existing object back into setContentOffset(), preserving the same generation rather than inventing a new intent
  • all cancellation boundaries call consumePendingContentOffsetOwner()
  • direct replacement calls replacePendingContentOffsetOwner()
  • rejected operations may restore the captured object only when the slot is still vacant; a callback-installed owner always wins

This closes same-payload ABA: an immediate child-rectangle scroll callback can reinstall target 500, and the outer request no longer mistakes that newer object for the owner it consumed.

Reentrancy closure

The owner is consumed before any physical write or accepted semantic command that can synchronously dispatch callbacks.

  • tryApplyPendingSetContentOffset() consumes before calling production setContentOffset().
  • Ready-range setContentOffset() consumes before internalSetContentOffset(). The former clear-after-write expression could erase a newer owner installed synchronously by the scroll callback.
  • A ready-range contentOffset also revokes any older custom-animation identity before cancellation and before the newer physical write. Cancellation callbacks therefore cannot apply a completion tail, and later old frames cannot turn 1000 into 1500 after production retry.
  • A default RecyclerView animation that production actually starts is bound to the same opaque ready owner in activeRecyclerViewContentOffsetOwner. Every newer ready write publishes its owner first, revokes the old active identity, and stops the old ViewFlinger before applying. Old RecyclerView frames therefore cannot turn a newer absolute 1000 into 1500.
  • A default ViewFlinger can inherit the TYPE_NON_TOUCH child connection opened by an older Kuikly custom animation. RecyclerView 1.1 ViewFlinger.stop() only removes callbacks and aborts its scroller; it skips the natural terminal branch that calls stopNestedScroll(TYPE_NON_TOUCH). Takeover therefore closes that inherited connection explicitly after revoking the old active identity and before IDLE callbacks. Cleanup remains active-RV scoped, so an unrelated non-touch connection is never over-stopped.
  • NestedScrollingChildHelper.stopNestedScroll() calls the parent before clearing its own parent pointer. A parent onStopNestedScroll(TYPE_NON_TOUCH) callback can therefore install a newer custom animation that sees the doomed old pointer, incorrectly treats itself as connected, and loses nesting when the helper clears the pointer after callback return. The takeover now stops the predecessor scroller without publishing state, closes the inherited connection, then asks only the still-current custom manager to establish a fresh connection after helper bookkeeping completes.
  • The same parent callback may install a newer RecyclerView ViewFlinger. The ready owner is captured before teardown and revalidated after the parent callback; when that newer custom/RecyclerView transport is active, outer cleanup skips forced IDLE entirely. A callback-installed immediate/no-op still receives the required terminal IDLE because it has no active transport to carry the chain.
  • The parent callback may instead install a range-not-ready pending owner. Its opaque generation is retained independently of the nullable pending slot, so the outer call detects the newer generation even if another synchronous boundary has already consumed the slot. The old outer write returns before its scrollBy() can erase or overwrite that callback owner; when the range grows, production retry lands the pending absolute target.
  • When parent-stop did not install a newer active transport, the takeover publishes IDLE synchronously after the inherited connection is closed. The outer call revalidates both ready and pending owner generations after those callbacks and returns if a newer immediate, pending, custom, or RecyclerView owner was installed. Old identity is revoked before the callback, so cleanup cannot erase the callback-installed identity.
  • RecyclerView 1.1's natural ViewFlinger.run() uses the opposite lifecycle entry: it publishes IDLE callbacks first and only then calls stopNestedScroll(TYPE_NON_TOUCH). A callback-installed custom animation initially sees the doomed inherited pointer and skips its own start. The stopNestedScroll(type) completion hook now reconnects only a still-current custom manager after child-helper bookkeeping clears the old pointer; active takeover calls super.stopNestedScroll() and retains its independent explicit reconnect. A callback-installed default ViewFlinger remains the unnested control.
  • A natural Kuikly custom-animation terminal enters stopScrollTransport() directly and closes TYPE_NON_TOUCH nesting before its final state cleanup. The parent stop callback may install a newer custom or RecyclerView owner while the old helper pointer is still visible. stopScrollTransport() snapshots the opaque ready owner before that external callback and checks the post-callback owner plus the transport production actually started. If a newer custom/RecyclerView transport exists, it skips the predecessor's stale stopScroll() so the successor remains the final writer; if no owner or only an IMMEDIATE no-op owner exists, the predecessor still closes itself normally.
  • RecyclerView stopScroll() performs a second stopScrollersInternal() after IDLE listeners return, which would kill a callback-installed ViewFlinger. The takeover instead uses the existing reflected setScrollState(IDLE) seam: RecyclerView stops the old flinger before callbacks and performs no post-callback stop.
  • The intermediate IDLE end is deferred during takeover. An immediate replacement flushes it after the physical write; a custom/RecyclerView replacement consumes it at terminal IDLE. The chain therefore emits exactly one scrollEnd, neither zero nor two.
  • KRScrollAnimationManager publishes the candidate animation object before SETTLING callbacks and starts it only if the same object is still current afterward. A ready callback may replace it with an immediate, RecyclerView, or newer custom transport; the outer start then performs no old write.
  • Ready-write transport identity is opaque and records the transport production actually starts. A zero-distance or layout-suppressed animation request is recorded as immediate, so cleanup closes the canceled custom state/nesting; a callback-installed RecyclerView/custom animation is recorded as active and remains untouched. If suppressLayout(true) has already published IDLE/end, cleanup closes only the still-live TYPE_NON_TOUCH child transport and does not reset a concurrent drag or duplicate end. A scroll-end generation closes RecyclerView's reverse-listener reentrancy without missing or duplicating scrollEnd.
  • Direct fling consumes before willEndDrag; that callback may install a newer deferred owner and it survives.
  • Accessibility consumes before super.performAccessibilityAction(). RecyclerView 1.1 can synchronously enter SCROLL_STATE_SETTLING; a state callback's newer owner survives. A rejected action restores the old token only if vacant.
  • Child-rectangle requests consume before super. Immediate scroll callbacks and immediate=false settling callbacks may install newer owners, including the same payload; rejected/visible requests restore the old token only if vacant.
  • Public scrollBy remains a direct-command cancellation gate, but KRScrollAnimationManager frames are already fenced by currentAnimation === animation object identity and advance through scrollByFromCustomAnimation. Those frames are transport for an existing intent, so a later frame cannot consume a newer owner installed by the animation's synchronous SETTLING/scroll callbacks.

Complete owner-entry audit

Boundary Rule Executable proof
Later contentOffset fresh object replaces the prior owner older/newer deferred targets, then physical apply
Ready-range contentOffset consumes before write immediate apply; real scroll-callback reentrancy preserves newer owner
Range/layout/content-size growth retains the same token first still-short layout retains; later ready range applies
Same direction property retains later range growth applies the current owner
Direction change consumes old-axis coordinates old owner never applies after row/column replacement
Compose reuse consumes old binding previous binding cannot resurrect
Explicit abort consumes waiting animation owner later range growth performs no old write
Ordinary drag consumes at physical-drag entry later range growth performs no old write
Overscroll drag consumes before callback-null return works with no drag callback
Nested TYPE_TOUCH nonzero requested physical motion consumes acceptance/touch-down alone does not over-cancel
Nested TYPE_NON_TOUCH consume only after consumed/actual child motion consumed movement clears; unconsumed request preserves
Parent nested consumption public nonzero scrollBy() consumes actual parent motion uses the direct-command gate
Direct scrollBy(x,y) nonzero command consumes; zero distance retains paired cases certify both sides
Kuikly custom linear/spring frame current animation object identity fences the frame; explicit transport seam bypasses direct-command cancellation real 7-field curve starts, SETTLING callback installs a range-deferred owner, 50 ms advances physical content while owner survives
Deferred owner becomes ready during custom animation ready write revokes/cancels the old animation before applying linear/spring manager active → range expands → production retry writes 1000 → quiescence still 1000, never 1500
Ready owner replaces not-yet-started custom animation animation identity is published before SETTLING and revalidated after callback linear/spring callback writes 1000; outer animation never starts and final offset remains 1000
Ready RecyclerView animation replaces not-yet-started custom animation opaque ready transport prevents old cleanup from killing the newer built-in animation attached real RecyclerView reaches 1000, ends IDLE, and emits one end
Ready immediate replaces running RecyclerView animation revoke old ViewFlinger identity, stop it, then apply the absolute write attached real RecyclerView reaches 1000 synchronously and remains [1000, IDLE, end=1] after two seconds; VOID production ended at 1500
Ready custom replaces running RecyclerView animation stop old ViewFlinger before starting custom transport all transports quiesce at [1000, IDLE, end=1]; VOID production ended at 1387
Ready RecyclerView animation replaces running RecyclerView animation stop old generation, then bind/start the new generation final [1000, IDLE, end=1]; active identity is cleared only at terminal IDLE
Stop/IDLE callback installs newer custom transport revoke old identity before IDLE; outer owner revalidation aborts middle write callback transport reaches 1500, custom manager stops, active-RV identity is null, IDLE, one end
Stop/IDLE callback installs newer RecyclerView transport no post-callback second stop; preserve callback-installed owner/flinger callback flinger reaches 1500, active identity clears at terminal IDLE, one end
Custom nesting → genuine RV → newer immediate revoke old RV identity, close inherited child connection before IDLE, then apply terminal [1500, IDLE, end=1, nested=false, start=1, stop=1, activeRV=false]; VOID production leaked [nested=true, stop=0]
Custom nesting → genuine RV → newer custom close inherited connection before the new custom owner starts takeover boundary is [manager=true, nested=true, start=2, stop=1]; terminal start/stop is 2/2 with one end
Custom nesting → genuine RV → newer RV close inherited connection without opening a replacement connection replacement starts SETTLING with start=1, stop=1, nested=false; terminal offset is exact with one end
Inherited stop/IDLE callback installs newer custom/RV old stop is observable before callback installation; outer cleanup never touches the new owner callback sees [start=1, stop=1, nested=false]; long custom stays active/nested with a fresh start, callback RV stays active without nesting, both terminate exactly once
Parent onStopNestedScroll callback installs newer custom/RV predecessor scroller is inert before the external callback; callback owner is revalidated before forced IDLE; current custom reconnects after child-helper pointer cleanup inside callback the doomed pointer is still visible at start=1/stop=1; after return custom is [SETTLING, manager=true, nested=true, start=2, stop=1], RV is [SETTLING, activeRV=true, nested=false]; both finish at 1500 with one end
Parent onStopNestedScroll callback installs newer immediate ready generation revalidation aborts the older outer write while allowing terminal IDLE callback target 1500 remains the final physical winner with one end and exact start=1/stop=1
Parent onStopNestedScroll callback installs newer range-deferred owner retained pending generation revalidation aborts the older outer write before it can consume the callback owner callback sees pending 2200; it survives outer return, then range growth/retry lands exact 2200, IDLE, one end, no active transport
Natural ViewFlinger IDLE callback installs newer custom/RV reconnect a still-current custom only after the old natural stopNestedScroll clears its doomed pointer; do not give custom nesting to a default RV replacement callback sees inherited start=1/stop=0/nested=true; custom finishes exact 1500/end=1/start=2/stop=2, paired RV finishes exact with start=1/stop=1
Natural custom terminal parent-stop callback installs newer custom/RV snapshot the ready owner around the external nested-stop callback and skip only the predecessor's stale stopScroll() when a different owner actually started an active transport callback-RV remains SETTLING/active after old helper cleanup and reaches exact 1500 with start=1/stop=1; callback-custom reconnects and closes at 2/2; no-successor control still terminates exact at 500
Ready write with no active contentOffset RV owner inherited-close seam is identity-scoped an unrelated accepted TYPE_NON_TOUCH connection remains open (start=1, stop=0) while the immediate offset lands
Explicit abort / no-op custom replacement identity is revoked before cancel; transport closes once no completion tail, IDLE, TYPE_NON_TOUCH parent stopped, scrollEnd == 1, stable after 11 seconds
Zero-distance RecyclerView replacement requested animation starts no ViewFlinger and is recorded as immediate canceled custom manager leaves IDLE, TYPE_NON_TOUCH stopped once, one end, stable offset after 11 seconds
Layout-suppressed RecyclerView replacement suppressed request starts no ViewFlinger; an already-IDLE old transport may still own TYPE_NON_TOUCH nesting nested child transport stops once without a second end or drag-state reset; offset stays fixed after 11 seconds
Public smoothScrollBy 2→3→4 public overload funnel consumes nonzero command real KRPagerSnapHelper.snapFromFling() no-touch path
Direct fling consumes before callbacks no-touch fling clears; willEndDrag newer owner survives
smoothScrollToPosition separate pre-super gate; LayoutManager/SmoothScroller does not use the smooth-by funnel production direct-position case
scrollToPosition separate pre-super gate production direct-position case
Accessibility forward/backward accepted consumes before callback; rejected restores accepted/rejected pair plus settling-callback reentry
Mouse wheel / rotary nonzero scroll axis consumes real generic motion physically scrolls and clears
Zero-axis generic event retains later range growth still applies
scrollEnabled=false generic event rejects inherited physical motion and retains content stays at zero; owner survives
Child rectangle, immediate accepted command consumes; callback may replace physical motion plus different- and same-payload reentry
Child rectangle, smooth accepted internal smooth command consumes immediate=false cancellation and settling reentry
Visible/rejected child rectangle restores old token if vacant no physical movement; owner remains
Generic settling retains an old transport-state callback cannot erase a newer owner
Final destruction consumes departed page cannot replay later

Generic SCROLL_STATE_SETTLING remains deliberately not a blanket cancellation gate: it describes transport mechanics, not the owner, and an old settling callback can occur after a newer programmatic intent. Ownership is taken at the semantic entry points above.

Production behavior fixture

KRRecyclerViewPendingContentOffsetTest uses real KRRecyclerView, real KRRecyclerContentView, production call("contentOffset", ...), a real first RecyclerView layout, real touch/generic/accessibility/native-command paths, real OnScrollListener callbacks, the real KRPagerSnapHelper.snapFromFling() path, and the exact retry hook production onLayout() invokes after later range growth.

RecyclerView 1.1's Robolectric shadow cannot replace and re-layout the single synthetic adapter child's LayoutParams without losing its synthetic ViewHolder. For the post-placement range transition, the fixture therefore measures/layouts the same real content child to the ready range and invokes that exact production retry hook. The limitation is documented beside the helper.

The final 66 cases cover:

  1. root not-ready survival/final physical apply, replacement, ready apply, and ready-write callback reentrancy;
  2. direction preserve/replace, Compose reuse, abort, ordinary drag, callback-less overscroll, nested touch, and destruction;
  3. consumed versus unconsumed nested non-touch motion;
  4. accepted/rejected Accessibility plus synchronous settling-callback reentrancy;
  5. direct nonzero/zero scrollBy, real SnapHelper/public smooth-by, direct fling and fling callback reentrancy, direct smooth/immediate position commands;
  6. nonzero/zero/disabled generic motion;
  7. immediate/smooth/rejected child-rectangle commands, different-target reentrancy, real immediate=false settling reentrancy, and same-payload ABA reinstallation;
  8. generic settling preservation;
  9. real linear and spring custom-animation frames: each starts from a 7-field animated contentOffset, installs a newer range-deferred owner in the synchronous SETTLING callback, physically advances for 50 ms, and preserves that owner.
  10. linear and spring quiescent final-winner races: old manager active, callback owner deferred, real range expansion and production retry apply 1000, then all old frames exhaust without moving the final offset to 1500;
  11. linear and spring ready callback replacement before the first frame, plus an attached RecyclerView-animation replacement that must not be killed by old custom cleanup;
  12. linear and spring abort-no-tail plus custom/RecyclerView no-op replacement: cancellation position stays fixed for 11 seconds, state becomes IDLE, TYPE_NON_TOUCH nesting stops, and scrollEnd is emitted exactly once;
  13. layout-suppressed RecyclerView replacement: RecyclerView has already published IDLE/end, no ViewFlinger starts, and the canceled custom transport's remaining TYPE_NON_TOUCH parent is stopped once without duplicate end.
  14. the complete old-RecyclerView transport row: ready immediate/custom/RecyclerView replacements all advance every participant to quiescence and preserve the newer physical target; paired IDLE-callback cases install an even newer custom/RecyclerView transport and prove the middle outer owner aborts without clearing or stopping it.
  15. the inherited-history layer: custom opens TYPE_NON_TOUCH, a genuine ViewFlinger inherits it, then immediate/custom/RV takeover closes the old connection with exact start/stop counts; paired IDLE callbacks prove pre-callback close and protect a fresh long-running custom or RecyclerView transport; a separate scope tooth preserves an unrelated non-touch connection when no active contentOffset ViewFlinger exists.
  16. the parent-stop synchronous window: callback-internal snapshots prove the child helper still exposes the doomed pointer, while post-return snapshots prove a current custom reconnects and a current RecyclerView owner survives without outer forced IDLE; both advance to exact quiescence.
  17. the completed parent-stop owner matrix: an immediate control proves ready-generation revalidation, while a range-not-ready callback owner is visible inside the callback, survives the outer stale write, and replays to exact offset 2200 only after the native range expands.
  18. the natural-terminal sibling window: a genuine inherited ViewFlinger reaches natural IDLE, callback custom/RV owners both observe the doomed start=1/stop=0 pointer, then only the custom owner reconnects after helper cleanup and closes at exact 2/2; the default-RV control remains 1/1.
  19. the natural custom-terminal parent-stop window: callback-installed custom/RV owners observe the predecessor pointer during onStopNestedScroll; after helper cleanup a real RecyclerView ViewFlinger and a reconnected custom owner both survive stale outer cleanup and reach exact 1500, while a no-successor control proves the predecessor still stops itself at 500.

RED → GREEN → deletion/mutation evidence

Root and existing boundaries:

  • exact landed production with the root fixture: RED after the first still-not-ready layout because the pending write disappeared
  • restore old retry-then-clear ordering: root survival/final-apply and newer-owner cases RED; immediate ready-range remains green
  • restore old ready-write clear-after-callback ordering: exactly the ready-write reentrancy case RED
  • remove each reuse, ordinary drag, callback-less overscroll, abort, nested-touch, direction-change, destruction, accepted Accessibility, direct scroll/fling/position, fling-before-callback, or generic-motion gate: its matching executable case turns RED
  • ignore consumed TYPE_NON_TOUCH: exactly the consumed-movement case RED
  • clear every TYPE_NON_TOUCH request: exactly the unconsumed-request preservation case RED
  • clear zero-axis generic input: exactly the zero-axis preservation case RED
  • blanket-clear generic settling: exactly the settling-preservation case RED
  • before the scrollEnabled=false early return, inherited RecyclerView behavior physically moved the list and the disabled case was RED

Successor mutations against the opaque-owner suite:

  1. delete the overridden 4-arg public smooth funnel gate → exactly pagerSnapHelperSmoothScrollWithoutTouchCancelsPendingOffsetOwner RED
  2. restore old Accessibility clear-after-super → exactly accessibilityScrollDoesNotEraseNewOwnerInstalledReentrantlyByStateCallback RED
  3. regress child-rectangle to String-payload equality → exactly childRectangleScrollDoesNotEraseSameTargetReinstalledByScrollCallback RED
  4. delete rejected Accessibility restore → exactly rejectedAccessibilityScrollKeepsPendingOffsetOwner RED
  5. delete rejected child-rectangle restore → exactly visibleChildRectangleRequestKeepsPendingOffsetOwner RED
  6. route identity-fenced custom-animation frames back through public scrollBy → only the paired linear/spring frame-owner cases RED; the original 34 remain GREEN

Final lifecycle mutations against this successor:

  1. cancel before revoking animation identity → four cases RED: linear/spring abort move 0 → 500, while custom and RecyclerView no-op replacements receive the old completion tail
  2. remove ready-write custom cancellation → seven cases RED: deferred and ready linear/spring end 1000 → 1500; the callback-installed RecyclerView transport and both RecyclerView no-start replacements also fail to replace the old custom animation
  3. publish animation identity after SETTLING and remove post-callback revalidation → only three cases RED: ready linear/spring end 1000 → 1500; the callback-installed RecyclerView transport is again superseded
  4. remove scroll-end generation completion → only ready linear/spring replacement cases RED: physical offset is correctly 1000, but scrollEnd changes 1 → 0
  5. ignore opaque ready-transport kind and always run old cleanup → only the callback-installed RecyclerView-animation case RED, final offset 1000 → 0
  6. route custom frames back through public scrollBy → only the paired frame-owner cases RED
  7. report a rejected zero-distance/layout-suppressed RecyclerView request as started → only the two RecyclerView no-start cases RED; the real callback-installed ViewFlinger case stays green
  8. delete the non-SETTLING nested-only cleanup → only the layout-suppressed case RED with [nested=true, stop=0]; the zero-distance/end cases stay green

Cross-transport takeover mutations against the pre-history 51-case layer:

  1. delete old-RecyclerView cancellation → four RED: RV→immediate returns 1500, RV→custom returns 1387, and both IDLE-callback cases remain callbackCount=0; RV→RV remains the natural-replacement control GREEN
  2. delete post-stop owner revalidation → only the two callback-installed winner cases RED; both finish at 1586 instead of 1500
  3. revoke active-RV identity after IDLE callbacks → only stopCallbackRecyclerViewOffsetSupersedesTheOuterRecyclerViewTakeover RED because the callback-installed active identity is erased
  4. use stopScroll() instead of the pre-callback-only state seam → only the callback-installed RecyclerView case RED at [offset=0, SETTLING, end=0, active=true]; the post-callback second stop killed its flinger
  5. delete active-RV identity publication → six RED: the five new cases that start the old default ViewFlinger fail their explicit active-owner binding, and readyRecyclerViewAnimationInstalledBySettlingCallbackSupersedesCustomAnimation ends at 0 instead of 1000; the other 45 cases remain GREEN
  6. delete deferred-end handoff → only RV→immediate RED at [1000, IDLE, end=0]
  7. dispatch instead of deferring the intermediate end → only RV→custom and RV→RV RED, both with end=2

Inherited-history mutations against the pre-parent-callback 57-case layer:

  1. delete the pre-IDLE inherited close → exactly five RED: history→immediate/custom/RV plus both history callback cases; the previous 51 and the new scope control remain GREEN
  2. move the close after IDLE callbacks → exactly the two history callback cases RED; the long custom remains manager=true but outer cleanup leaves it nested=false, with observed boundary before=[start=1, stop=0, nested=true], after start=1
  3. remove active-RV ownership scoping → the dedicated unowned-connection tooth and existing custom/history nested-ownership teeth turn RED, proving cleanup cannot be generalized to arbitrary TYPE_NON_TOUCH state

Parent-stop callback-window mutations against the 61-case suite:

  1. remove both post-callback owner revalidation and fresh-custom reconnect → exactly the two new cases RED; custom reproduces the reviewer failure at [IDLE, manager=true, activeRV=false, nested=false, start=1, stop=1], while callback RV is forced to IDLE and loses active identity
  2. remove only the post-helper custom reconnect → exactly the custom case RED at [SETTLING, manager=true, nested=false, start=1, stop=1]; the newer owner survives but has no child connection
  3. remove only post-parent owner revalidation → exactly both new cases RED; custom is forced from SETTLING to IDLE and callback RV loses active identity
  4. leave the predecessor ViewFlinger running through the parent callback → exactly the custom case RED after quiescence, final offset 1500 → 1586; the old flinger remains a physical writer
  5. delete only pending-generation revalidation while retaining ready-owner revalidation → exactly the pending case RED: callback sees the new owner, but the older outer write consumes it; immediate/custom/RV controls remain GREEN

Natural ViewFlinger-terminal mutation against the 63-case layer:

  1. delete only the natural stopNestedScroll(TYPE_NON_TOUCH) post-helper reconnect → exactly the natural callback-custom case RED at terminal start=1/stop=1 instead of 2/2; the paired callback-RV control and the previous 61 cases remain GREEN

Natural custom-terminal mutation against the final 66-case suite:

  1. delete only the ready-owner/actual-transport preservation branch around the parent stop callback → exactly the callback-installed RecyclerView case RED, expected terminal offset 1500 versus actual 500; callback-custom, no-successor control, and the previous 63 cases remain GREEN

Every mutation was restored before the final focused/full runs; final focused fixture is 66/66 GREEN.

Local validation

  • focused KRRecyclerViewPendingContentOffsetTest — 66 tests, 0 failures/errors/skips
  • ./gradlew :core-render-android:testDebugUnitTest --no-daemon — 154 tests, 0 failures/errors/skips
  • PR fix(compose): replay pending offset after viewport shrink #117 KNodeViewportShrinkPendingOffsetTest — 4 tests, 0 failures/errors/skips
  • ./gradlew :compose:testDebugUnitTest --no-daemon — 175 tests, 0 failures/errors/skips
  • git diff --check HEAD^ HEAD — GREEN
  • no mutation markers; clean worktree

Exact scope and provenance

  • exact: dee8264ebd5f6911415f7e136cce6a9130b9dc79
  • base/parent: d691975d12f7454a476b63d7bc2137cd226708af
  • tree: 796043466b4e3a9e8da5c1dec08c26b09746423d
  • stable patch-id: 1dfa5e90e20adf6c34b36cd77b5f1efe0035e937
  • exactly three paths: KRRecyclerView, KRScrollAnimationManager, and the Android renderer production-behavior fixture
  • blobs: 3d189407e95ed367c34f26e5c410fe776588b74c, 319c80848aff6dc37f9343e3222d8719169d5c2b, 4147ad269100d509ad622805e5833343ec67b18c
  • one commit; author/committer MingQi <mingqi@mail.build>; matching real Signed-off-by trailer

All older review/Hosted evidence is explicitly VOID and does not transfer:

  • heads 83959ec6f047bb2b4ec0d673af7d5c5206165d6e, 0bfde6978ae5a6396b9f721b789f26a1eb085bed, 904a129b7230d35c16164891be7190f3e4a3a618, f85c6d3a56e7811674e4a165447106d604f96728, 571d50550822b28735b571a916daadfe3c159e19, efe67f16a8aa5c3cfbc36b17f060f0528cfaab00, ee15f7f9645e06319cf9524c209f9b3c31201be1, 4d293964ce5629cffce26457e2638bfcb3ef6723, 5af46c08e0d1f1ddc6a208591bca0c827601e2d5, 32f5198a042f84306a7bb4bab5afaca6a91ed2df, bbc23f13788dba2f6836bb22b2c6970c2c842f5a, and 337228306a8b71436af954a158bce9c41645787f
  • Hosted runs 30941996178, 30941996464, 30945397905, 30945397924, 30951649087, 30951649040, 30954604049, 30954604060, 30957469314, 30957469317, 30964481287, 30964481317, 30967595784, 30967595775, 30971866522, 30971866519, 30975135018, 30975135014, 30978346840, 30978346823, 30981073062, 30981073078, 30989672138, and 30989671939

Fresh exact-bound Hosted is terminal GREEN on dee8264ebd5f6911415f7e136cce6a9130b9dc79:

  • Compose Exact Matrix 30995655442 — completed SUCCESS; all six jobs completed SUCCESS. Artifact 8926143959 is named common-core-android-dee8264ebd5f6911415f7e136cce6a9130b9dc79; downloaded XML reports Compose 175/0/0/0, Android renderer 154/0/0/0, focused pending-offset fixture 66/0/0/0, and KNode viewport-shrink fixture 4/0/0/0 for tests/failures/errors/skipped.
  • Android Render PR 30995655431 — completed SUCCESS; checkout reports expected head = actual head = dee8264ebd5f6911415f7e136cce6a9130b9dc79, and its independently parsed renderer XML reports 154/0/0/0.

Every check and artifact bound to 33722830… remains VOID and does not transfer.

Fresh remote readback has the topic ref and PR head at the same exact, staging2 still at d691975d12f7454a476b63d7bc2137cd226708af, one signed three-path commit, and a live merge-tree byte-equal to 796043466b4e3a9e8da5c1dec08c26b09746423d. PR #118 is OPEN, non-draft, MERGEABLE/CLEAN, with all seven displayed checks completed SUCCESS. The one-commit/DCO/three-path/blob/patch-id contract, marker scan, committed diff-check, and worktree are clean. No older remote or Hosted fact transfers.

Fresh Tencent semantic-divergence review remains part of the next exact-bound reviewer seat; no upstream-equivalence claim is inherited from an older head.

This PR closes only a Kuikly renderer lower-layer source path. Mobile carrier PR #1959 must not merge. The corrected current-main carrier 163c64128202babb152c80f599d048d08a48b08a (VC1000201) contains this exact, passed package/source/signature and schema-preserving launch admission, and still reproduced the white Thread viewport on the physical SGT-AL10; Hands 0404ea46-caa5-416b-83b8-a1197e97af5b is the current product RED. Therefore neither the source exact nor its carrier is a stand-alone product A/B PASS. Final product closure requires the independent Mobile focused-correction readiness fix, fresh exact-bound source/Hosted review for every changed head, and a new combined carrier that keeps the focused target visibly rendered through the 622→315dp IME shrink with native/Compose offset convergence, no stale-owner resurrection, and no crash/ANR. Until that physical observation passes, product status remains RED.

@bytemain
bytemain force-pushed the mingqi/daf40beb-android-pending-range branch 11 times, most recently from bbc23f1 to 3372283 Compare August 5, 2026 08:35
Signed-off-by: MingQi <mingqi@mail.build>
@bytemain
bytemain force-pushed the mingqi/daf40beb-android-pending-range branch from 3372283 to dee8264 Compare August 5, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant