-
Notifications
You must be signed in to change notification settings - Fork 173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
refactor(container,lazy,hooks): avoid blocking JS thread by refactoring SharedValue.value access #423
refactor(container,lazy,hooks): avoid blocking JS thread by refactoring SharedValue.value access #423
Conversation
React.useEffect(() => { | ||
if (index.value >= tabNamesArray.length) { | ||
onTabPress(tabNamesArray[tabNamesArray.length - 1]) | ||
useAnimatedReaction( | ||
() => tabNamesArray.length, | ||
(tabLength) => { | ||
if (index.value >= tabLength) { | ||
runOnJS(onTabPress)(tabNamesArray[tabLength - 1]) | ||
} | ||
} | ||
}, [index.value, onTabPress, tabNamesArray]) | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is essentially the same as was just instead of running inside a useEffect it runs inside a useAnimatedReaction to avoid blocking the JS thread.
|
||
const focusedTab: ContextType['focusedTab'] = | ||
useDerivedValue<TabName>(() => { | ||
return tabNames.value[index.value] | ||
}, [tabNames]) | ||
const calculateNextOffset = useSharedValue(index.value) | ||
const calculateNextOffset = useSharedValue(initialIndex) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is safe to do because the initial value passed to useSharedValue will only be captured once on first pass, this is not reactive.
(event: LayoutChangeEvent) => { | ||
const latestHeight = event.nativeEvent.layout.height | ||
if (latestHeight !== height) { | ||
setHeight(latestHeight) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's safe make a layout value a regular React state because updates to this will not cause an animation change, these values are only used as static values within animation triggers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this check necessary? doesn't react not even re-render if the value is the same?
I believe this is actually an anti-pattern, since now getHeight
relies on the height
variable, causing it to update its reference based on its parent state when this isn't necessary.
For example, you could just dispatch it with a callback too, right?
setHeight(height => height === latestHeight ? height : latestHeight)
However, that becomes the same as just doing setHeight(latestHeight)
.
Maybe I'm missing something, but I believe that's the case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe relevant: facebook/react#28779
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also see:
https://legacy.reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update
Bailing out of a state update
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.
The problem here is that all the code in the component will run again unnecessarily, even though it doesn't go "deeper" or actually render anything. By preventing setting the state, we can optimize against that.
Will look over this tomorrow. Thanks for contributing! |
Very interesting, I didn't know about this behavior of Reanimated. Is it documented anywhere? Could it be a bug in Reanimated, maybe a recent regression? I don't remember running into an issue before or hearing about SharedValue blocking JS threads. @piaskowyk could you kindly shed some light? See Andrei Calazan's blog post and reddit posts: I remember introducing |
Hey @andreialecu, thanks for reviewing. I also didn't know this until now, not sure if it's a regression since the behavior kinda of makes sense if you think about it. Any time you need to read a SharedValue.value from within the JavaScript thread it needs to bridge the value synchronously from the HostObject so that can have a non-trivial cost (anywhere from 5 to 20ms depending on device). I want to highlight that the problem is not using SharedValue but repeatedly needing to read it from within the JS thread. The example diff you shared is a good example of the correct pattern which is to take something from the JS thread and run it in worklet to save CPU cost in the JS thread. In the blogpost I tried to come up with a mental model of when something should be a SharedValue. |
Are you using the new architecture? With Paper/old architecture, as far as I remember, calling |
@andreialecu I'm not using the new arch nor did I test in it. |
@andreialecu note that my changes avoids this interchange of SharedValue to regular React state and solely relies on React state where possible. |
@@ -242,7 +236,7 @@ export const Container = React.memo( | |||
) | |||
|
|||
useAnimatedReaction( | |||
() => headerHeight.value, | |||
() => headerHeight, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this actually work right? Can reanimated trigger a reaction based on a react state change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, it does work, I added logs to validate.
I understand. It's just that this transition to animated values was actually done on purpose, and it greatly improved performance. I'm trying to understand what changed recently for this to no longer be the case. If accessing shared values from JS was indeed such performance-intensive, I think that it would've been discovered by others in the past. I find it odd that this is the first time I'm hearing about this, and I couldn't find other mentions of this potential problem anywhere else. Reanimated has regressions some times, so it would be great to understand more about this from an upstream maintainer. Perhaps we can ping one on Twitter/X |
What I think would help is if you could create a separate, minimal repro of this SharedValue issue, as a quick benchmark. Then we can perhaps run it on various reanimated versions and see if it's indeed a regression. |
@andreialecu I understand the pushback. It will be helpful to hear from @piaskowyk to see if my understanding is correct. I think the Example app is enough of a minimal repro. I pushed here a simple setup that allows us to run performance tests using Flashlight. I ran these e2e tests with 10 iterations for At a first glance it seems like nothing has changed and this branch is just slightly better, but take a look at when the FPS starts dipping when we navigate to the FlashList example. On
Flashlight reports: To visualize these yourself install Flashlight and run |
I released this as 8.0.1-rc.0 to npm for the time being, so that we can all test it more easily. |
const shouldStartMounted = | ||
typeof _startMounted === 'boolean' | ||
? _startMounted | ||
: focusedTab.value === name |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems that reading .value
in render is not recommended, according to: https://x.com/kzzzf/status/1805222107011317861
This is not your fault though, the code previously did this too.
Released latest changes as 8.0.1-rc.1 |
40fb293
to
d8be0d9
Compare
This is a performance optimization to avoid blocking the JavaScript thread. BREAKING CHANGE: headerHeight, tabBarHeight, containerHeight, and contentInset are no longer SharedValues. If you consume useHeaderMeasurements and/or useTabsContext expect this to impact you.
d8be0d9
to
08a1c51
Compare
Hey @andreialecu and @gkartalis (tagging bc I can't add reviewers), I was introducing this library to solve a UI pattern and noticed blocking JavaScript thread behavior that caused a regression of over 500ms to our initial screen render.
The flame graph above shows that the JavaScript thread is blocked by executeOnUIRuntimeSync for quite some time impacting the initial render time of a screen by over 500ms.
On closer analysis I found that the problem is caused by reading a SharedValue.value in the main JS thread. This library does this quite often in places that could avoid it. This PR refactors most of the places where it does not need to be a SharedValue while trying to not cause any regressions.
I understand there isn't unit or e2e tests for this library so I tried my best to test the Example app. I posted multiple recordings below of how it currently works with these changes.
I also wrote a detailed blogpost explaining this Reanimated blocking behavior.
You can also inspect the before and after CPU profilings, I stored them here. The CPU profile will show that before these changes there were 57 calls to executeOnUIRuntimeSync when navigating from the main menu to the FlashList example in the Example app and now there are only 14 calls.
Demos
demo-1-reduced.mp4
demo-2-reduced.mp4
demo-3-reduced.mp4
demo-4-reduced.mp4
demo-5-reduced.mp4
demo-6-reduced.mp4
demo-7-reduced.mp4
demo-8-reduced.mp4