Introduce hysteresis window (#53345)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/53345

Changelog: [Internal] - Introduce hysteresis window that is nested between the prerender and hidden window sizes. Currently set to enlargening the prerender window by hysteresis ratio

When a VirtualView intersects with the hysteresis window, it mode remains unchanged.

This prevents us dispatch mode changes for things like overscroll.

I put the hysteresis between prerender and hidden because we already avoid dispatching mode changes from visible -> prerender. For prerender -> visible, we use renderState

Reviewed By: yungsters

Differential Revision: D80511627

fbshipit-source-id: cd14256abc898e7120705e277147d52a06c865a9
This commit is contained in:
Luna Wei
2025-08-25 12:58:22 -07:00
committed by Facebook GitHub Bot
parent 870836ff84
commit 792e450aff
22 changed files with 178 additions and 29 deletions
@@ -230,21 +230,34 @@ static BOOL sIsAccessibilityUsed = NO;
scrollView.contentOffset.y,
scrollView.frame.size.width,
scrollView.frame.size.height);
const CGFloat visibleWidth = thresholdRect.size.width;
const CGFloat visibleHeight = thresholdRect.size.height;
if (CGRectOverlaps(targetRect, thresholdRect)) {
newMode = RCTVirtualViewModeVisible;
} else {
auto prerender = false;
const CGFloat prerenderRatio = ReactNativeFeatureFlags::virtualViewPrerenderRatio();
if (prerenderRatio > 0) {
thresholdRect = CGRectInset(
thresholdRect, -thresholdRect.size.width * prerenderRatio, -thresholdRect.size.height * prerenderRatio);
thresholdRect = CGRectInset(thresholdRect, -visibleWidth * prerenderRatio, -visibleHeight * prerenderRatio);
prerender = CGRectOverlaps(targetRect, thresholdRect);
}
if (prerender) {
newMode = RCTVirtualViewModePrerender;
} else {
newMode = RCTVirtualViewModeHidden;
thresholdRect = CGRectZero;
const CGFloat hysteresisRatio = ReactNativeFeatureFlags::virtualViewHysteresisRatio();
if (_mode.has_value() && hysteresisRatio > 0) {
thresholdRect = CGRectInset(thresholdRect, -visibleWidth * hysteresisRatio, -visibleHeight * hysteresisRatio);
if (CGRectOverlaps(targetRect, thresholdRect)) {
newMode = _mode.value();
} else {
newMode = RCTVirtualViewModeHidden;
thresholdRect = CGRectZero;
}
} else {
newMode = RCTVirtualViewModeHidden;
thresholdRect = CGRectZero;
}
}
}
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<1e8a84a53072fa0c8665aead80d0199f>>
* @generated SignedSource<<34c12f5a31aab5bfb874953f1beefef1>>
*/
/**
@@ -432,6 +432,12 @@ public object ReactNativeFeatureFlags {
@JvmStatic
public fun useTurboModules(): Boolean = accessor.useTurboModules()
/**
* Sets a hysteresis window for transition between prerender and hidden modes.
*/
@JvmStatic
public fun virtualViewHysteresisRatio(): Double = accessor.virtualViewHysteresisRatio()
/**
* Initial prerender ratio for VirtualView.
*/
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<d07316109dce5cc1ce7b0dc010962c3c>>
* @generated SignedSource<<392da016e0bf4193b72c44a508811e10>>
*/
/**
@@ -87,6 +87,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
private var useShadowNodeStateOnCloneCache: Boolean? = null
private var useTurboModuleInteropCache: Boolean? = null
private var useTurboModulesCache: Boolean? = null
private var virtualViewHysteresisRatioCache: Double? = null
private var virtualViewPrerenderRatioCache: Double? = null
override fun commonTestFlag(): Boolean {
@@ -692,6 +693,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
return cached
}
override fun virtualViewHysteresisRatio(): Double {
var cached = virtualViewHysteresisRatioCache
if (cached == null) {
cached = ReactNativeFeatureFlagsCxxInterop.virtualViewHysteresisRatio()
virtualViewHysteresisRatioCache = cached
}
return cached
}
override fun virtualViewPrerenderRatio(): Double {
var cached = virtualViewPrerenderRatioCache
if (cached == null) {
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<6e384a07f0e7bc237f72b49a1268e16b>>
* @generated SignedSource<<a0453230524ebca2bfb8fad656a6f54a>>
*/
/**
@@ -162,6 +162,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
@DoNotStrip @JvmStatic public external fun useTurboModules(): Boolean
@DoNotStrip @JvmStatic public external fun virtualViewHysteresisRatio(): Double
@DoNotStrip @JvmStatic public external fun virtualViewPrerenderRatio(): Double
@DoNotStrip @JvmStatic public external fun override(provider: Any)
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<2daac10f81c205728db8127da4dd36cc>>
* @generated SignedSource<<719706a983a073b6c286c49d993f7f80>>
*/
/**
@@ -157,5 +157,7 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
override fun useTurboModules(): Boolean = false
override fun virtualViewHysteresisRatio(): Double = 0.0
override fun virtualViewPrerenderRatio(): Double = 5.0
}
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<1a19e0569371a038ba8d3849fb26eb5c>>
* @generated SignedSource<<594815ba6a984c460ab8bddd91c5cae2>>
*/
/**
@@ -91,6 +91,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
private var useShadowNodeStateOnCloneCache: Boolean? = null
private var useTurboModuleInteropCache: Boolean? = null
private var useTurboModulesCache: Boolean? = null
private var virtualViewHysteresisRatioCache: Double? = null
private var virtualViewPrerenderRatioCache: Double? = null
override fun commonTestFlag(): Boolean {
@@ -763,6 +764,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
return cached
}
override fun virtualViewHysteresisRatio(): Double {
var cached = virtualViewHysteresisRatioCache
if (cached == null) {
cached = currentProvider.virtualViewHysteresisRatio()
accessedFeatureFlags.add("virtualViewHysteresisRatio")
virtualViewHysteresisRatioCache = cached
}
return cached
}
override fun virtualViewPrerenderRatio(): Double {
var cached = virtualViewPrerenderRatioCache
if (cached == null) {
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<5f84a9a17209b9ecb95e13e6567e7c3c>>
* @generated SignedSource<<dfbd5e84392f1fda0e68324582c328b2>>
*/
/**
@@ -157,5 +157,7 @@ public interface ReactNativeFeatureFlagsProvider {
@DoNotStrip public fun useTurboModules(): Boolean
@DoNotStrip public fun virtualViewHysteresisRatio(): Double
@DoNotStrip public fun virtualViewPrerenderRatio(): Double
}
@@ -41,6 +41,7 @@ public class ReactVirtualView(context: Context) :
internal var modeChangeEmitter: VirtualViewModeChangeEmitter? = null
internal var prerenderRatio: Double = ReactNativeFeatureFlags.virtualViewPrerenderRatio()
internal val debugLogEnabled: Boolean = ReactNativeFeatureFlags.enableVirtualViewDebugFeatures()
private val hysteresisRatio: Double = ReactNativeFeatureFlags.virtualViewHysteresisRatio()
private val onWindowFocusChangeListener =
if (ReactNativeFeatureFlags.enableVirtualViewWindowFocusDetection()) {
@@ -222,6 +223,8 @@ public class ReactVirtualView(context: Context) :
bottom + offsetY,
)
scrollView.getDrawingRect(thresholdRect)
val visibleHeight = thresholdRect.height()
val visibleWidth = thresholdRect.width()
// TODO: Validate whether this is still the case and whether these checks are still needed.
// updateRects will initially get called before the targetRect has any dimensions set, so if
@@ -260,16 +263,32 @@ public class ReactVirtualView(context: Context) :
var prerender = false
if (prerenderRatio > 0.0) {
thresholdRect.inset(
(-thresholdRect.width() * prerenderRatio).toInt(),
(-thresholdRect.height() * prerenderRatio).toInt(),
(-visibleWidth * prerenderRatio).toInt(),
(-visibleHeight * prerenderRatio).toInt(),
)
prerender = rectsOverlap(targetRect, thresholdRect)
}
if (prerender) {
newMode = VirtualViewMode.Prerender
} else {
newMode = VirtualViewMode.Hidden
thresholdRect.setEmpty()
val _mode = mode // local variable so Kotlin knows its not nullable
if (_mode != null && hysteresisRatio > 0.0) {
thresholdRect.inset(
(-visibleWidth * hysteresisRatio).toInt(),
(-visibleHeight * hysteresisRatio).toInt(),
)
if (rectsOverlap(targetRect, thresholdRect)) {
// In hysteresis window, no change to mode
newMode = _mode
debugLog("dispatchOnModeChangeIfNeeded") { "hysteresis, mode=$newMode" }
} else {
newMode = VirtualViewMode.Hidden
thresholdRect.setEmpty()
}
} else {
newMode = VirtualViewMode.Hidden
thresholdRect.setEmpty()
}
}
}
debugLog("dispatchOnModeChangeIfNeeded") {
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<1eff5bade524e3ad8e827ad4adb37f1a>>
* @generated SignedSource<<16b12024bb363358ef09b9a42cb2fc97>>
*/
/**
@@ -441,6 +441,12 @@ class ReactNativeFeatureFlagsJavaProvider
return method(javaProvider_);
}
double virtualViewHysteresisRatio() override {
static const auto method =
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jdouble()>("virtualViewHysteresisRatio");
return method(javaProvider_);
}
double virtualViewPrerenderRatio() override {
static const auto method =
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jdouble()>("virtualViewPrerenderRatio");
@@ -786,6 +792,11 @@ bool JReactNativeFeatureFlagsCxxInterop::useTurboModules(
return ReactNativeFeatureFlags::useTurboModules();
}
double JReactNativeFeatureFlagsCxxInterop::virtualViewHysteresisRatio(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
return ReactNativeFeatureFlags::virtualViewHysteresisRatio();
}
double JReactNativeFeatureFlagsCxxInterop::virtualViewPrerenderRatio(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
return ReactNativeFeatureFlags::virtualViewPrerenderRatio();
@@ -1023,6 +1034,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
makeNativeMethod(
"useTurboModules",
JReactNativeFeatureFlagsCxxInterop::useTurboModules),
makeNativeMethod(
"virtualViewHysteresisRatio",
JReactNativeFeatureFlagsCxxInterop::virtualViewHysteresisRatio),
makeNativeMethod(
"virtualViewPrerenderRatio",
JReactNativeFeatureFlagsCxxInterop::virtualViewPrerenderRatio),
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<05dba4cd49bd4f490e1ea943dd02cae0>>
* @generated SignedSource<<54118ccd475a8bf1d7db83304b1f17d0>>
*/
/**
@@ -231,6 +231,9 @@ class JReactNativeFeatureFlagsCxxInterop
static bool useTurboModules(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
static double virtualViewHysteresisRatio(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
static double virtualViewPrerenderRatio(
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<a7bd9fcbbcb19ee8a939b4c08e0fd508>>
* @generated SignedSource<<12a06ea04fc09c34f1fbdcbdf6046d81>>
*/
/**
@@ -294,6 +294,10 @@ bool ReactNativeFeatureFlags::useTurboModules() {
return getAccessor().useTurboModules();
}
double ReactNativeFeatureFlags::virtualViewHysteresisRatio() {
return getAccessor().virtualViewHysteresisRatio();
}
double ReactNativeFeatureFlags::virtualViewPrerenderRatio() {
return getAccessor().virtualViewPrerenderRatio();
}
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<4831f7b871223d8347db93f9a0b3a1f9>>
* @generated SignedSource<<eee81e4e9bb13ef5134d4e2d79876b38>>
*/
/**
@@ -374,6 +374,11 @@ class ReactNativeFeatureFlags {
*/
RN_EXPORT static bool useTurboModules();
/**
* Sets a hysteresis window for transition between prerender and hidden modes.
*/
RN_EXPORT static double virtualViewHysteresisRatio();
/**
* Initial prerender ratio for VirtualView.
*/
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<d5243da4eb6bdde1e703ae55fcccbf0f>>
* @generated SignedSource<<3c5588a851e6cdefaba22236c5ebb828>>
*/
/**
@@ -1235,6 +1235,24 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModules() {
return flagValue.value();
}
double ReactNativeFeatureFlagsAccessor::virtualViewHysteresisRatio() {
auto flagValue = virtualViewHysteresisRatio_.load();
if (!flagValue.has_value()) {
// This block is not exclusive but it is not necessary.
// If multiple threads try to initialize the feature flag, we would only
// be accessing the provider multiple times but the end state of this
// instance and the returned flag value would be the same.
markFlagAsAccessed(67, "virtualViewHysteresisRatio");
flagValue = currentProvider_->virtualViewHysteresisRatio();
virtualViewHysteresisRatio_ = flagValue;
}
return flagValue.value();
}
double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() {
auto flagValue = virtualViewPrerenderRatio_.load();
@@ -1244,7 +1262,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() {
// be accessing the provider multiple times but the end state of this
// instance and the returned flag value would be the same.
markFlagAsAccessed(67, "virtualViewPrerenderRatio");
markFlagAsAccessed(68, "virtualViewPrerenderRatio");
flagValue = currentProvider_->virtualViewPrerenderRatio();
virtualViewPrerenderRatio_ = flagValue;
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<ce6cfc25358a636fcd9cd6605e9008ed>>
* @generated SignedSource<<f1eb31a7412bff743a5c581224d71e2a>>
*/
/**
@@ -99,6 +99,7 @@ class ReactNativeFeatureFlagsAccessor {
bool useShadowNodeStateOnClone();
bool useTurboModuleInterop();
bool useTurboModules();
double virtualViewHysteresisRatio();
double virtualViewPrerenderRatio();
void override(std::unique_ptr<ReactNativeFeatureFlagsProvider> provider);
@@ -111,7 +112,7 @@ class ReactNativeFeatureFlagsAccessor {
std::unique_ptr<ReactNativeFeatureFlagsProvider> currentProvider_;
bool wasOverridden_;
std::array<std::atomic<const char*>, 68> accessedFeatureFlags_;
std::array<std::atomic<const char*>, 69> accessedFeatureFlags_;
std::atomic<std::optional<bool>> commonTestFlag_;
std::atomic<std::optional<bool>> cdpInteractionMetricsEnabled_;
@@ -180,6 +181,7 @@ class ReactNativeFeatureFlagsAccessor {
std::atomic<std::optional<bool>> useShadowNodeStateOnClone_;
std::atomic<std::optional<bool>> useTurboModuleInterop_;
std::atomic<std::optional<bool>> useTurboModules_;
std::atomic<std::optional<double>> virtualViewHysteresisRatio_;
std::atomic<std::optional<double>> virtualViewPrerenderRatio_;
};
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<47b7dd2d84a9ea6c2e89677ac5b5554e>>
* @generated SignedSource<<a76f1a1e8ba0d65b689b4b87d33d7ced>>
*/
/**
@@ -295,6 +295,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider {
return false;
}
double virtualViewHysteresisRatio() override {
return 0.0;
}
double virtualViewPrerenderRatio() override {
return 5.0;
}
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<6030ed59832efdf42e23c13b689da64f>>
* @generated SignedSource<<e2a5086e5586caf4c90ef503416a0e83>>
*/
/**
@@ -648,6 +648,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef
return ReactNativeFeatureFlagsDefaults::useTurboModules();
}
double virtualViewHysteresisRatio() override {
auto value = values_["virtualViewHysteresisRatio"];
if (!value.isNull()) {
return value.getDouble();
}
return ReactNativeFeatureFlagsDefaults::virtualViewHysteresisRatio();
}
double virtualViewPrerenderRatio() override {
auto value = values_["virtualViewPrerenderRatio"];
if (!value.isNull()) {
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<95940b74b0055760be69c501a97a6f45>>
* @generated SignedSource<<feb44bb7ec97d29787eac070103f9e1b>>
*/
/**
@@ -92,6 +92,7 @@ class ReactNativeFeatureFlagsProvider {
virtual bool useShadowNodeStateOnClone() = 0;
virtual bool useTurboModuleInterop() = 0;
virtual bool useTurboModules() = 0;
virtual double virtualViewHysteresisRatio() = 0;
virtual double virtualViewPrerenderRatio() = 0;
};
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<998bdf037d58ca747c22ada099d236f0>>
* @generated SignedSource<<c9c36c1dbece9e27f7b71da7611cb747>>
*/
/**
@@ -379,6 +379,11 @@ bool NativeReactNativeFeatureFlags::useTurboModules(
return ReactNativeFeatureFlags::useTurboModules();
}
double NativeReactNativeFeatureFlags::virtualViewHysteresisRatio(
jsi::Runtime& /*runtime*/) {
return ReactNativeFeatureFlags::virtualViewHysteresisRatio();
}
double NativeReactNativeFeatureFlags::virtualViewPrerenderRatio(
jsi::Runtime& /*runtime*/) {
return ReactNativeFeatureFlags::virtualViewPrerenderRatio();
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<89c5d22cd228da57d82843f7e660f262>>
* @generated SignedSource<<320e69fa54228a352fad210e3a43b947>>
*/
/**
@@ -170,6 +170,8 @@ class NativeReactNativeFeatureFlags
bool useTurboModules(jsi::Runtime& runtime);
double virtualViewHysteresisRatio(jsi::Runtime& runtime);
double virtualViewPrerenderRatio(jsi::Runtime& runtime);
};
@@ -760,6 +760,17 @@ const definitions: FeatureFlagDefinitions = {
},
ossReleaseStage: 'canary',
},
virtualViewHysteresisRatio: {
defaultValue: 0,
metadata: {
dateAdded: '2025-08-22',
description:
'Sets a hysteresis window for transition between prerender and hidden modes.',
expectedReleaseValue: 1,
purpose: 'experimentation',
},
ossReleaseStage: 'none',
},
virtualViewPrerenderRatio: {
defaultValue: 5,
metadata: {
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<cb505b80eebdf982b88aebf4c37843da>>
* @generated SignedSource<<bb685c713f0fdecb402fc14a6158d5ae>>
* @flow strict
* @noformat
*/
@@ -114,6 +114,7 @@ export type ReactNativeFeatureFlags = $ReadOnly<{
useShadowNodeStateOnClone: Getter<boolean>,
useTurboModuleInterop: Getter<boolean>,
useTurboModules: Getter<boolean>,
virtualViewHysteresisRatio: Getter<number>,
virtualViewPrerenderRatio: Getter<number>,
}>;
@@ -449,6 +450,10 @@ export const useTurboModuleInterop: Getter<boolean> = createNativeFlagGetter('us
* When enabled, NativeModules will be executed by using the TurboModule system
*/
export const useTurboModules: Getter<boolean> = createNativeFlagGetter('useTurboModules', false);
/**
* Sets a hysteresis window for transition between prerender and hidden modes.
*/
export const virtualViewHysteresisRatio: Getter<number> = createNativeFlagGetter('virtualViewHysteresisRatio', 0);
/**
* Initial prerender ratio for VirtualView.
*/
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<2b0679c42d8e1cb021612807d94d9f4c>>
* @generated SignedSource<<da22f4c43e3bdcd999e3dd1dd5896c63>>
* @flow strict
* @noformat
*/
@@ -92,6 +92,7 @@ export interface Spec extends TurboModule {
+useShadowNodeStateOnClone?: () => boolean;
+useTurboModuleInterop?: () => boolean;
+useTurboModules?: () => boolean;
+virtualViewHysteresisRatio?: () => number;
+virtualViewPrerenderRatio?: () => number;
}