Skip to content

Share explicit scroll snap selection with Kotlin Multiplatform - #58475

Open
kunal26das wants to merge 10 commits into
react:mainfrom
kunal26das:codex/kmp-scroll-snap-stack
Open

Share explicit scroll snap selection with Kotlin Multiplatform#58475
kunal26das wants to merge 10 commits into
react:mainfrom
kunal26das:codex/kmp-scroll-snap-stack

Conversation

@kunal26das

@kunal26das kunal26das commented Sep 10, 2026

Copy link
Copy Markdown

Summary

Native stacked review: Review this layer in the registered six-PR fork stack. This PR remains the upstream submission to react/react-native.

Share explicit snapToOffsets target selection between Android's vertical, horizontal and nested scroll views and Apple's enhanced scroll view. This is the third use case after the KMP foundation #58472, gradients #58456 and multipart framing #58474. Each use case is independently reviewable; there is no Compose dependency.

Review this layer: scroll implementation and validation (13 paths). The full upstream diff includes earlier fork-branch prerequisites because GitHub does not support cross-fork stacked bases. The independent native fixes in #58471 and #58473 are also prerequisites in the branch history.

The common selector is 131 lines and returns a small object containing target, selected offset and direction; Apple reads its target offset. Each view retains native prediction, velocity adjustment, overscroll, density, RTL and axis handling, interval snapping and scrolling physics. Apple caches a primitive offset array when the property changes and calls Kotlin once per fling. Android retains the original caller-owned integer list, including subsequent mutations, and preserves its integer overflow behavior. Empty-list behavior is preserved separately for horizontal versus vertical/nested views. Catalyst retains its native path. Apple source builds remain opt-in with RCT_USE_KMP=1.

Why consider this boundary?

The selector is duplicated across three Kotlin views and an Objective-C++ view, and its small target/direction result keeps the interop boundary small. Common tests centralize decision behavior while platform adapters keep their differences explicit. This is a maintenance proposal, not a claim that Kotlin is faster than a well-tuned native implementation. React Native's existing C++ infrastructure and a native caching change are both credible alternatives.

The benchmark therefore includes the original Apple implementation, a test-only cached native control, and KMP. Each uses the actual view delegate method and matched inputs. Medians across three fresh processes per configuration, in nanoseconds per warm call:

Offset count Original native Cached native KMP
3 224 72 204
16 443 102 215
256 5,682 595 868

Caching explains much of the improvement over the original NSNumber loop. The cached native control is faster than KMP in every configuration. KMP's first property assignment measured 130–207 µs versus 1.2–3.6 µs for cached native; its first fling measured 14.5–27.6 µs. The isolated KMP executable was 832,432 bytes larger than the original native executable, but includes the runtime and all three shared use cases. This is neither an incremental scroll-only size nor an installed application size.

These optimized ARM64 simulator probes measure method calls, not frame timing, physical gestures or device performance. Common-source ownership and shared regression coverage must justify the added runtime/build burden; the results do not establish a performance reason to adopt KMP.

Changelog:

[GENERAL] [CHANGED] - Share explicit scroll snap-offset target selection on Android and opt-in KMP iOS source builds.

Test Plan

The scroll implementation is byte-identical to reviewed head 9cc8379; this restack only inherits the gradient-layer hosted-runtime fix. The inherited multipart graph passes 112 configurations on each of CocoaPods 1.14.2 and 1.15.2. Fresh focused runtime checks are recorded in the prerequisite PRs.

Runtime-ownership coverage includes both nested and sibling hosted XCTest bundles. Hosted tests use the application’s Kotlin runtime, while standalone tests keep their own runtime. The retained three-use-case library/static-framework runs at 9cc8379 each passed 154 active tests under Address Sanitizer and launched successfully.

From packages/react-native/ReactShared:

./gradlew jvmTest iosSimulatorArm64Test --max-workers=2
python3 scripts/test-apple-scroll-snap.py --benchmark-repeats 0
# Omit --benchmark-repeats 0 for the three-implementation cost probe.

The full ReactAndroid suite was run from the repository with:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --rerun \
  -PreactNativeArchitectures=arm64-v8a -Preact.internal.useHermesStable=true \
  --max-workers=2 --console=plain --offline
  • All 38 common tests pass on both JVM and ARM64 iOS simulator, including six selector tests; all six Debug/Release iOS device and simulator frameworks build, including Intel simulator slices. Nine checked-in Android regressions pass. The regressions call the real three views' fling paths and cover velocity/direction, free ranges, ties, axes/RTL, mutable lists, empty lists, endpoints and integer overflow.

  • An additional Robolectric differential fixture compiles the full original and changed Android view classes and passes 80,640 target/velocity/overscroll comparisons with controlled prediction and drawing. These are separate from the 18 ART assertions across API 24/26/37. Its two local baseline tests are not included in the nine checked-in tests.

  • The external direct-fling fixture initially reproduced the same nested-scroll bookkeeping issue with both native and KMP AARs. Initializing that state through AndroidX’s public API made the unchanged 80/96 assertions pass for both; no production code or expected target was changed. This fixture covers zero-velocity LTR snapping and retained-list updates, not general gesture/RTL physics.

  • Actual Apple native/KMP view comparison: 30,282 cases passed. The cached native control independently passed the same 30,282 comparisons. Catalyst compiles without Kotlin references.

  • Cost probe: 27 fresh processes across three implementations, three list sizes and three repeats; all checksums agree. No other task-owned benchmark/build ran during the measured window. Source hashes identify the combined shared framework used.

  • The assembled stack passed the full ReactAndroid suite: 623 tests across 94 suites, zero failures/errors/skips; RNTester ARM64 Release and 16 KiB alignment passed.

  • Previously validated local Maven consumers and packed-npm source consumers passed Debug and R8 Release. Source consumption builds standard Hermes from source. Five AARs contain each of the 12 shared class entries exactly once, and bytecode checks confirm actual calls from all three use cases.

  • Both Release consumers passed real gradient and multipart assertions on ARM64 API 24, 26 and 37. A separate R8 fixture passed six explicit-offset assertions per API using the real vertical, horizontal and nested views, including retained-list updates. RNTester exercised ten gradient routes and three normal scroll routes on each API 26/37, plus two gradient routes on API 24. These are targeted assertions and UI smoke, not exhaustive gestures or pixel comparisons.

  • Retained static-library and static-framework RNTester runs at 9cc8379 each passed 154 tests, with 16 upstream skips and zero failures under Address Sanitizer, and launched. These repeat the same suite, not 308 distinct tests. Compiled adapters call all three shared APIs. The application owns one runtime; hosted integration tests import it, while standalone unit tests own their runtime separately.

  • Universal Catalyst source build and actual ARM64/Rosetta x86_64 launches passed representative linear/radial rendering checks. All six compiled adapter objects use native fallback, and the final binaries have no shared Kotlin runtime. This is Catalyst fallback coverage, not Intel iOS simulator execution.

  • Four independent Kotlin runtime/static-dynamic combinations passed. An empty Objective-C owner linked with -ObjC retains all shared APIs. A separate licensed XCFramework/SwiftPM distribution consumer builds for device/simulator/Catalyst and passes one ARM64 simulator test, reusing the matching Release framework archives. This does not enable complete React Native core SwiftPM/prebuilt adoption.

  • The final assembled package includes all 44 shared-module files and excludes shared generated build outputs and caches.

  • Retained unsigned iOS ARM64 Release source build at 9cc8379 passed. All three actual adapter objects call their shared APIs, and the app executable owns one Kotlin runtime. No physical-device execution is claimed.

  • Dynamic-framework RNTester on ARM64 iOS simulator passed 154 tests, with 16 upstream skips and no failures, and launched. The actual compiled scroll object references RNSScrollSnapOffsets, alongside the gradient and multipart adapters; React.framework owns the runtime once in the app load graph.

The cost run used 1d628d8e0d6; Android application validation used 586f67bafb46 plus a multipart test annotation. Their production algorithms, adapters and relevant build inputs match this layer. Retained Apple static-library, static-framework, unsigned device and Catalyst validation uses 9cc8379d0ad8. Updated hosted-test resolution comes from the gradient prerequisite; the scroll implementation and adapters remain unchanged. Dynamic execution is retained from 586f67bafb46: the changed CocoaPods hosted-test logic is outside its unchanged dynamic path, the dynamic runtime owner is unchanged, and the preserved binaries passed the ownership guard. It is not a new final-commit dynamic build or run. Common/framework validation at 265de92 and separate distribution/runtime checks at 772c54b have unchanged production inputs. These app tests cover the combined stack, rather than a scroll-only application.

Intel simulator execution remains deferred, and physical-device execution is unavailable. Full React Native core SwiftPM/prebuilt adoption is still guarded. Correctness/integration passes do not establish an accepted product performance budget.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 10, 2026
@kunal26das
kunal26das force-pushed the codex/kmp-scroll-snap-stack branch from 265de92 to 772c54b Compare September 10, 2026 19:34
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 10, 2026
@kunal26das
kunal26das force-pushed the codex/kmp-scroll-snap-stack branch from 772c54b to 9cc8379 Compare September 10, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant