Reanimated, useSharedValue, and third-party defined types in the Compiler #14
Replies: 4 comments 5 replies
-
Hey @tjzel, thanks for the extensive writeup! The compiler team met to discuss this today and i'll share a writeup of our takeaways and suggestions. Our approach was to focus on the core concept that is being modeled, and separate this out from other aspects such as API (getters/setters vs functions) and implementation details. I'll first describe how we think about modeling reanimated and the implications of that model on the API design, and then propose a more pragmatic compromise solution. Modeling the Use-CaseIn this case, there's an external system (the animator) that the product code is manipulating. Updating a shared value mutates that external system, which is a side-effect. Reading the value synchronizes with that external system to get the current value, and that external system may continue to change such that calling read again at a later time returns a different value. That means the read is also a side effect. At a conceptual level, therefore, shared values are really analogous to external mutable stores. React has a few different APIs for synchronizing with external mutable stores. Stated another way: refs are a one-off type in React that is not meant to be imitated in other APIs, but rather composed. The long-term "correct" way to do this would be storing the mutable store references inside refs and accessing them through the ref. Whether to use getters/setters or functions ( An AlternativeI can imagine that the above is not a very satisfactory answer since it means a breaking API change for y'all. However, understanding the model does help us understand the long-term direction that we should take. Since the right pattern is to use a ref, we don't want to encourage APIs that copy refs rather than compose them. That in turns means we don't need to generalize the compiler to support other instances of this pattern. Instead, we're hoping we can find a pragmatic solution. Reanimated is unique in our knowledge and we're inclined to try to carve out an exception in this case. We're thinking that we can teach the compiler to ignore "worklet" functions when checking for side effects. This would allow keeping the existing api of shared values, and allow setting I'm curious for your thoughts on this, and whether the idea of allowing mutations inside "worklet" functions could work as a compromise. |
Beta Was this translation helpful? Give feedback.
-
Apologies for my late reply - I was essentially out of office last week and needed appropriate time to answer properly. It's an honor that your team is investing time to discuss Reanimated - we appreciate it. Relating to the read/write side-effect impacting the external system, I stand corrected, you're right to see it as a side-effect. Our stance aligns with this, since we already discourage users from using getters and setters in render. Thanks for highlighting this. Reducing the boilerplateYour idea of wrapping the Shared Value in a Ref is a simple and compelling solution. However, it conflicts with what we perceive the community wants. We frequently receive requests to reduce boilerplate in our APIs, yet this move would increase it. Users might then wrap function useSharedValue(initial) {
return Reanimated.useSharedValue(initial).current;
} In my opinion this is bound to happen since it virtually requires no other changes to the user's code had we shipped this kind of change. CompositionI hope I didn't misunderstood what you meant by composition here. A while back I noticed that the Compiler does not allow all valid forms of composition. I know myself the limitations of Babel plugins and it's upsetting that we can't get a wider perspective on the code with it. That said, we should not restrict ourselves due to our tools. Here's an example: function useCustomHook() {
const ref = useRef<CustomObject>(null!);
if (ref.current === null) {
ref.current = initializer();
}
useEffect(() => {
ref.current.doSomethingInternally();
});
return ref.current.publicObject;
}
export default function App() {
const customObject = useCustomHook();
useEffect(() => {
customObject.prop += 1; // Error reported by the compiler.
});
return (
<View style={styles.container}>
<Text>Hello world!</Text>
</View>
);
} Depending on context, this could be valid or invalid.
If the Compiler had stronger tools this wouldn't be an issue, but unfortunately it's not the case now. The current rules of React are fairly new and changes like these feel slightly radical to me. Reanimated & the ecosystemIt would be much easier for us to conform to all your proposed changes had Reanimated been a simple, small library. However it's deeply embedded in the React Native ecosystem for a long time; it's utilized by other libraries, it's APIs are a part of other APIs:
Worklet-only approachThis proposal is compelling but has discrete issues.
For it to work in the current way, I only see the option where Compiler works after Reanimated's Babel plugin had done its job - however this is in direct contradiction with the principle of the Compiler running first. It could obviously ran before and after (maybe it is now already?) but I assume it would require extensive changes to the Compiler - correct me if I'm wrong here. Give ourselves timeWe really would like to work out some compromise here, but the intricacies of Reanimated, React and Compiler are getting in the way here. We are constantly experimenting with new ways of utilizing the tools available to us, trying to bring the worklets to a less invasive, more optimized and less opinionated state. That's why I was thinking if we could spread out over time the restrictions (or rather rule enforcement) that the Compiler brings to the table. I envision that with some time and now with adequate attention from Reanimates' team we could make that satisfying compromise happen. Disabling the error for Shared Value mutation outside of render by default for the time being would get this issue out of your way and give us room to develop better integration with the Compiler. As a start we can emphasize that we discourage Shared Value .value access in renders, both in the documentation and in the codebase. |
Beta Was this translation helpful? Give feedback.
-
Regarding the config setting for enableReanimatedCheck, will this allow usages of Reanimated's We're still seeing Thanks! |
Beta Was this translation helpful? Give feedback.
-
Since the new API for Shared Values was embraced well by the community, I'll close this discussion 🥳 |
Beta Was this translation helpful? Give feedback.
-
Hi, first of all, I want to thank you for your amazing effort on the React Compiler. It required very little work to enable it in our example apps for Reanimated testing purposes and it worked like a charm.
As agreed upon, I want to move our previous discussions here, sum it up, and present our point of view, as Reanimated maintainers, in a more organized manner. Reanimated works across both native and web platforms, but for simplicity, I'll stick mostly to the native cases here.
Summary
Here is a brief summary in a tl;dr format.
Currently, the Compiler considers the API of
useSharedValue
as breaking the rules of React. However, after our analysis ofuseSharedValue
, we consider that it does not do so. There are certain values stemming from the implicit use of getters and setters in its implementation, and while it might seem that Shared Values are mutable objects, they are not; they don't affect renders and depending on them during render is a bad practice, discouraged by the Reanimated team.What is the issue?
With the introduction of the React Compiler, the API utilized by Shared Values is considered as an error, since the Compiler falsely detects illegal mutations. Take a look at the following example:
However, this kind of code forms the backbone of how the Reanimated API works. We'd like to explain why, in our opinion, this pattern should be allowed and how it could possibly be implemented.
How Reanimated works
To better understand the problem, it would be valuable to explain how Reanimated works in React Native.
Reanimated is a library that enables you to create animations in React Native. Its main advantage is speed - by running animations directly on the UI thread and not re-rendering components in the React JavaScript runtime, we can provide a smoother, more responsive experience for users. To achieve that, Reanimated initiates a new JavaScript runtime, which is run on the UI thread. I'll refer to it as a Worklet Runtime. However, animations are usually started from the React JavaScript runtime. Therefore, a way to transfer data from the React Runtime to the Worklet Runtime is needed. This is where
useSharedValue
comes in.Why
useSharedValue
Everything here applies to
useDerivedValue
as well.It's a hook designed to resemble the
useRef
hook. It doesn't affect the render process of React and it's mainly supposed to be utilized for Worklet Runtime purposes. The hook itself returns an object - which I'll refer to as a Shared Value - that has a getter and a setter on itsvalue
property. The actual value of the Shared Value resides in the Worklet Runtime.The getter does the following:
The situation is slightly different for the setter:
None of the setter calls change the reference of the Shared Value.
In general, we discourage the use of Shared Value's getter on the React Runtime. Handling them only on the Worklet Runtime is sufficient in the vast majority of cases. However, some very custom scenarios may still require their use. This should be done as a side effect - depending on Shared Value during render is a bad practice leading to errors - similar to reading from
useRef
during render.Why
useSharedValue
API is valuable in our opinionChanges to the Shared Value don't trigger a re-render. By having the
value
property on the Shared Value, we can intuitively show users the resemblance touseRef
and lead them to treat it as if it were a mutable object. This is crucial for us since it affords us a more approachable API, which we wouldn't get with an explicit getter and setter. Let's look at a slightly modified example from before:The use of an explicit getter and setter would make the
Gesture.Tap()
callback code look like this:Which, in our opinion, is harder to read and may adversely impact performance - unfortunately, worklets executed on the Worklet Runtime don't benefit from optimizations (inlining, etc.) that Hermes could provide. Handling the primitives gives us an edge over using lambda functions as arguments.
useSharedValue
and Rules of ReactHere, I will go over the rules that we believe apply to
useSharedValue
. Please let me know if I've overlooked something.Hooks must be pure
useSharedValue
honors this rule. On its first call, it creates the Shared Value on the Worklet Runtime (specifically, it prepares the data structure to be constructed on the Worklet Runtime, and the initialization itself is deferred until the first time the value is accessed). The hook always returns the same object with a getter and setter, since it's memoized byuseState
, and the setter is discarded.Side effects must run outside of Render
In our view,
useSharedValue
doesn't produce any side effects.Don’t mutate State, Return values and arguments to Hooks are immutable & Values are immutable after being passed to JSX
The Shared Value returned by
useSharedValue
is not mutable. It has getters and setters which only affect values on the Worklet Runtime.Depending on
useSharedValue
on renderUsing
useSharedValue
on render is discouraged by the Reanimated team. Since Shared Value'svalue
might be changing on the UI thread, subsequent reads from it on the React Runtime might yield different results. This isn't such a unique case, however. Given video players present in the React ecosystem, getting the current time of the video is a common use case, which yields different results on every call while the video is playing. We believe that developers don't expect this kind of data to trigger a re-render and also acknowledge the possibility that the value is out-of-sync. The same applies touseSharedValue
. The same would go for scroll offsets, etc. Looking at these examples:This would be invalid, as would be the case with Reanimated:
The proper solution in React would be to apply scroll handlers that trigger
useState
- the same case applies in Reanimated, as we provide such APIs.Hardcoding Reanimated into the Compiler
It's very flattering for us that as a React Native first library we are considered in the Compiler's code. However, I don't believe it's a good long term solution.
useDerivedValue
is a hook that utilizesuseSharedValue
under the hood. Suppose we wanted to add a new hook of this kind. We would need to submit a PR to the Compiler, which would take some time before it's available to users - meanwhile, they would be receiving warnings/errors from the Compiler. Suppose we wanted to change the behavior of some hook - it used to affect the render, and now it doesn't (or vice-versa). This is an even worse scenario, as it could lead users to break their code because they followed outdated definitions in the Compiler.While these scenarios might seem extreme, we are planning to decouple Reanimated's animations and Reanimated's UI thread utilization, namely worklets, into separate packages. This separation would definitely require contributions to the Compiler, which, again, would reach the users after a delay.
I think that React Native and its libraries are currently much more dynamic than those of React - breaking changes are more frequent and new ways to utilize the native APIs are being introduced all the time. Therefore, a more dynamic approach regarding Reanimated & Compiler integration would be beneficial, in our opinion.
I believe most of the discussion here arises from the fact that Reanimated is the only library handling multiple runtimes over native code, allowing the user to do virtually anything on them. While it might be sufficient for now to hardcode for Reanimated, other multi-threaded libraries of a similar nature are likely to arise in the future.
Could the compiler be configurable?
If we don't want to hardcode things into the Compiler, we could establish some configuration APIs. I understand that the Compiler's aim is to be a plug-n-play solution that shouldn't require any configuration from the user. We don't want to change it. Hence, as an alternative, we could employ the Babel pipeline.
Each Babel plugin can define a
pre
step for the transpilation process. In this step, we have access to every Babel plugin that is included, and we can modify their options. Back in the day, I made a PoC of this, which looked like this:While this only enabled the flag for Reanimated and such manipulation in Compiler's options looks like a hack, we could streamline this process. Since the Compiler is supposed to be listed first, its
pre
step would be run first, enabling it to expose some functions to facilitate dynamic configurations of this kind.I understand your points here regarding maintainability and debugging. It is true that third-party plugins could misuse these APIs and break the Compiler. However, as evident from the PoC, that remains a possibility even without them. Naturally, this is just one possible solution and there could be other ways - this approach could be useful for not just Reanimated but potentially other libraries too.
Thanks for reading. I wanted to keep it short but you know how it is 🙈. Please keep in mind that this is only our perspective on the matter, and we are open to any feedback. I would be more than happy to answer your questions and listen to your ideas.
Beta Was this translation helpful? Give feedback.
All reactions