fix(android): retain pending offset until content range is ready - #118
Open
bytemain wants to merge 1 commit into
Open
fix(android): retain pending offset until content range is ready#118bytemain wants to merge 1 commit into
bytemain wants to merge 1 commit into
Conversation
bytemain
force-pushed
the
mingqi/daf40beb-android-pending-range
branch
11 times, most recently
from
August 5, 2026 08:35
bbc23f1 to
3372283
Compare
Signed-off-by: MingQi <mingqi@mail.build>
bytemain
force-pushed
the
mingqi/daf40beb-android-pending-range
branch
from
August 5, 2026 10:00
3372283 to
dee8264
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
KRRecyclerViewcontentOffsetowner across every layout where the native content range is still too shortscrollByintent and erase a callback-installed ownercontentOffsetinvalidate an older custom animation before applying, so the newer intent remains the final physical winner after both participants quiesceValueAnimator.cancel()callbacks, preventing cancellation from applying the old completion tailinternalSetContentOffset()actually starts, rather than treating an animation request label as proof that a RecyclerView/custom scroller existsscrollEndcontentOffsetanimation to its opaque ready-owner identity and stop that exact older ViewFlinger before any newer ready writestopScroll()'s post-callback second scroller stop; use the existing state seam that stops the old ViewFlinger before IDLE callbacks without killing a callback-installed replacementonStopNestedScroll, the old scroller is already inertNestedScrollingChildHelperfinally 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 IDLEstopScroll(); requested animations that started no transport remainIMMEDIATEand still receive terminal cleanupscrollEndfor the whole replacement chainPhysical 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
87450samples,nonWhiteCount=0,whitePermille=1000. The same process/launch supplied a positive control only 9.493 seconds earlier withnonWhiteCount=5488,whitePermille=937.The production-path Android fixture confirms a necessary lower-layer defect in native range deferral:
setContentOffset()cannot scroll yet and reinstalls the command as the pending native owner.tryApplyPendingSetContentOffset()used to clear the slot after that retry, erasing the command it had just reinstalled.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=loadedcan 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.contentOffsetinstalls a fresh owner object, even if its serialized payload exactly equals the previous payloadsetContentOffset(), preserving the same generation rather than inventing a new intentconsumePendingContentOffsetOwner()replacePendingContentOffsetOwner()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 productionsetContentOffset().setContentOffset()consumes beforeinternalSetContentOffset(). The former clear-after-write expression could erase a newer owner installed synchronously by the scroll callback.contentOffsetalso 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 turn1000into1500after production retry.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 absolute1000into1500.ViewFlinger.stop()only removes callbacks and aborts its scroller; it skips the natural terminal branch that callsstopNestedScroll(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 parentonStopNestedScroll(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.scrollBy()can erase or overwrite that callback owner; when the range grows, production retry lands the pending absolute target.ViewFlinger.run()uses the opposite lifecycle entry: it publishes IDLE callbacks first and only then callsstopNestedScroll(TYPE_NON_TOUCH). A callback-installed custom animation initially sees the doomed inherited pointer and skips its own start. ThestopNestedScroll(type)completion hook now reconnects only a still-current custom manager after child-helper bookkeeping clears the old pointer; active takeover callssuper.stopNestedScroll()and retains its independent explicit reconnect. A callback-installed default ViewFlinger remains the unnested control.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 stalestopScroll()so the successor remains the final writer; if no owner or only anIMMEDIATEno-op owner exists, the predecessor still closes itself normally.stopScroll()performs a secondstopScrollersInternal()after IDLE listeners return, which would kill a callback-installed ViewFlinger. The takeover instead uses the existing reflectedsetScrollState(IDLE)seam: RecyclerView stops the old flinger before callbacks and performs no post-callback stop.scrollEnd, neither zero nor two.KRScrollAnimationManagerpublishes 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.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 duplicatingscrollEnd.willEndDrag; that callback may install a newer deferred owner and it survives.super.performAccessibilityAction(). RecyclerView 1.1 can synchronously enterSCROLL_STATE_SETTLING; a state callback's newer owner survives. A rejected action restores the old token only if vacant.super. Immediate scroll callbacks andimmediate=falsesettling callbacks may install newer owners, including the same payload; rejected/visible requests restore the old token only if vacant.scrollByremains a direct-command cancellation gate, butKRScrollAnimationManagerframes are already fenced bycurrentAnimation === animationobject identity and advance throughscrollByFromCustomAnimation. 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
contentOffsetcontentOffsetTYPE_TOUCHTYPE_NON_TOUCHscrollBy()consumesscrollBy(x,y)1000→ quiescence still1000, never15001000; outer animation never starts and final offset remains10001000, ends IDLE, and emits one end1000synchronously and remains[1000, IDLE, end=1]after two seconds; VOID production ended at1500[1000, IDLE, end=1]; VOID production ended at1387[1000, IDLE, end=1]; active identity is cleared only at terminal IDLE1500, custom manager stops, active-RV identity is null, IDLE, one end1500, active identity clears at terminal IDLE, one end[1500, IDLE, end=1, nested=false, start=1, stop=1, activeRV=false]; VOID production leaked[nested=true, stop=0][manager=true, nested=true, start=2, stop=1]; terminal start/stop is2/2with one endstart=1, stop=1, nested=false; terminal offset is exact with one end[start=1, stop=1, nested=false]; long custom stays active/nested with a fresh start, callback RV stays active without nesting, both terminate exactly onceonStopNestedScrollcallback installs newer custom/RVstart=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 at1500with one endonStopNestedScrollcallback installs newer immediate1500remains the final physical winner with one end and exactstart=1/stop=1onStopNestedScrollcallback installs newer range-deferred owner2200; it survives outer return, then range growth/retry lands exact2200, IDLE, one end, no active transportstopNestedScrollclears its doomed pointer; do not give custom nesting to a default RV replacementstart=1/stop=0/nested=true; custom finishes exact1500/end=1/start=2/stop=2, paired RV finishes exact withstart=1/stop=1stopScroll()when a different owner actually started an active transportstart=1/stop=1; callback-custom reconnects and closes at2/2; no-successor control still terminates exact at 500start=1, stop=0) while the immediate offset landsscrollEnd == 1, stable after 11 secondssmoothScrollByKRPagerSnapHelper.snapFromFling()no-touch pathwillEndDragnewer owner survivessmoothScrollToPositionscrollToPositionscrollEnabled=falsegeneric eventimmediate=falsecancellation and settling reentryGeneric
SCROLL_STATE_SETTLINGremains 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
KRRecyclerViewPendingContentOffsetTestuses realKRRecyclerView, realKRRecyclerContentView, productioncall("contentOffset", ...), a real first RecyclerView layout, real touch/generic/accessibility/native-command paths, realOnScrollListenercallbacks, the realKRPagerSnapHelper.snapFromFling()path, and the exact retry hook productiononLayout()invokes after later range growth.RecyclerView 1.1's Robolectric shadow cannot replace and re-layout the single synthetic adapter child's
LayoutParamswithout 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:
scrollBy, real SnapHelper/public smooth-by, direct fling and fling callback reentrancy, direct smooth/immediate position commands;immediate=falsesettling reentrancy, and same-payload ABA reinstallation;contentOffset, installs a newer range-deferred owner in the synchronous SETTLING callback, physically advances for 50 ms, and preserves that owner.1000, then all old frames exhaust without moving the final offset to1500;scrollEndis emitted exactly once;2200only after the native range expands.start=1/stop=0pointer, then only the custom owner reconnects after helper cleanup and closes at exact2/2; the default-RV control remains1/1.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:
TYPE_NON_TOUCH: exactly the consumed-movement case REDTYPE_NON_TOUCHrequest: exactly the unconsumed-request preservation case REDscrollEnabled=falseearly return, inherited RecyclerView behavior physically moved the list and the disabled case was REDSuccessor mutations against the opaque-owner suite:
pagerSnapHelperSmoothScrollWithoutTouchCancelsPendingOffsetOwnerREDaccessibilityScrollDoesNotEraseNewOwnerInstalledReentrantlyByStateCallbackREDchildRectangleScrollDoesNotEraseSameTargetReinstalledByScrollCallbackREDrejectedAccessibilityScrollKeepsPendingOffsetOwnerREDvisibleChildRectangleRequestKeepsPendingOffsetOwnerREDscrollBy→ only the paired linear/spring frame-owner cases RED; the original 34 remain GREENFinal lifecycle mutations against this successor:
0 → 500, while custom and RecyclerView no-op replacements receive the old completion tail1000 → 1500; the callback-installed RecyclerView transport and both RecyclerView no-start replacements also fail to replace the old custom animation1000 → 1500; the callback-installed RecyclerView transport is again superseded1000, butscrollEndchanges1 → 01000 → 0scrollBy→ only the paired frame-owner cases RED[nested=true, stop=0]; the zero-distance/end cases stay greenCross-transport takeover mutations against the pre-history 51-case layer:
1500, RV→custom returns1387, and both IDLE-callback cases remaincallbackCount=0; RV→RV remains the natural-replacement control GREEN1586instead of1500stopCallbackRecyclerViewOffsetSupersedesTheOuterRecyclerViewTakeoverRED because the callback-installed active identity is erasedstopScroll()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 flingerreadyRecyclerViewAnimationInstalledBySettlingCallbackSupersedesCustomAnimationends at0instead of1000; the other 45 cases remain GREEN[1000, IDLE, end=0]end=2Inherited-history mutations against the pre-parent-callback 57-case layer:
manager=truebut outer cleanup leaves itnested=false, with observed boundarybefore=[start=1, stop=0, nested=true], after start=1Parent-stop callback-window mutations against the 61-case suite:
[IDLE, manager=true, activeRV=false, nested=false, start=1, stop=1], while callback RV is forced to IDLE and loses active identity[SETTLING, manager=true, nested=false, start=1, stop=1]; the newer owner survives but has no child connection1500 → 1586; the old flinger remains a physical writerNatural ViewFlinger-terminal mutation against the 63-case layer:
stopNestedScroll(TYPE_NON_TOUCH)post-helper reconnect → exactly the natural callback-custom case RED at terminalstart=1/stop=1instead of2/2; the paired callback-RV control and the previous 61 cases remain GREENNatural custom-terminal mutation against the final 66-case suite:
1500versus actual500; callback-custom, no-successor control, and the previous 63 cases remain GREENEvery mutation was restored before the final focused/full runs; final focused fixture is 66/66 GREEN.
Local validation
KRRecyclerViewPendingContentOffsetTest— 66 tests, 0 failures/errors/skips./gradlew :core-render-android:testDebugUnitTest --no-daemon— 154 tests, 0 failures/errors/skipsKNodeViewportShrinkPendingOffsetTest— 4 tests, 0 failures/errors/skips./gradlew :compose:testDebugUnitTest --no-daemon— 175 tests, 0 failures/errors/skipsgit diff --check HEAD^ HEAD— GREENExact scope and provenance
dee8264ebd5f6911415f7e136cce6a9130b9dc79d691975d12f7454a476b63d7bc2137cd226708af796043466b4e3a9e8da5c1dec08c26b09746423d1dfa5e90e20adf6c34b36cd77b5f1efe0035e937KRRecyclerView,KRScrollAnimationManager, and the Android renderer production-behavior fixture3d189407e95ed367c34f26e5c410fe776588b74c,319c80848aff6dc37f9343e3222d8719169d5c2b,4147ad269100d509ad622805e5833343ec67b18cMingQi <mingqi@mail.build>; matching realSigned-off-bytrailerAll older review/Hosted evidence is explicitly VOID and does not transfer:
83959ec6f047bb2b4ec0d673af7d5c5206165d6e,0bfde6978ae5a6396b9f721b789f26a1eb085bed,904a129b7230d35c16164891be7190f3e4a3a618,f85c6d3a56e7811674e4a165447106d604f96728,571d50550822b28735b571a916daadfe3c159e19,efe67f16a8aa5c3cfbc36b17f060f0528cfaab00,ee15f7f9645e06319cf9524c209f9b3c31201be1,4d293964ce5629cffce26457e2638bfcb3ef6723,5af46c08e0d1f1ddc6a208591bca0c827601e2d5,32f5198a042f84306a7bb4bab5afaca6a91ed2df,bbc23f13788dba2f6836bb22b2c6970c2c842f5a, and337228306a8b71436af954a158bce9c41645787f30941996178,30941996464,30945397905,30945397924,30951649087,30951649040,30954604049,30954604060,30957469314,30957469317,30964481287,30964481317,30967595784,30967595775,30971866522,30971866519,30975135018,30975135014,30978346840,30978346823,30981073062,30981073078,30989672138, and30989671939Fresh exact-bound Hosted is terminal GREEN on
dee8264ebd5f6911415f7e136cce6a9130b9dc79:30995655442— completed SUCCESS; all six jobs completed SUCCESS. Artifact8926143959is namedcommon-core-android-dee8264ebd5f6911415f7e136cce6a9130b9dc79; downloaded XML reports Compose175/0/0/0, Android renderer154/0/0/0, focused pending-offset fixture66/0/0/0, and KNode viewport-shrink fixture4/0/0/0for tests/failures/errors/skipped.30995655431— completed SUCCESS; checkout reports expected head = actual head =dee8264ebd5f6911415f7e136cce6a9130b9dc79, and its independently parsed renderer XML reports154/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,
staging2still atd691975d12f7454a476b63d7bc2137cd226708af, one signed three-path commit, and a live merge-tree byte-equal to796043466b4e3a9e8da5c1dec08c26b09746423d. 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; Hands0404ea46-caa5-416b-83b8-a1197e97af5bis 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 the622→315dpIME shrink with native/Compose offset convergence, no stale-owner resurrection, and no crash/ANR. Until that physical observation passes, product status remains RED.