Hanno Gödecke

React Native: From JS to the screen, the rendering pipeline debunked

Transcript

Hanno

Hi, everyone. Thank you so much for joining my talk today. I'm Hanno from Margello, and today we are going to debunk the rendering pipeline in React Native, specifically like the new fabric renderer that we have. What we're going to look at in this talk is how we go from something rather simple like our React components that we write to something very concrete, to something that's rendered on our mobile screen. Now, for me, I was always a very curious person. And when I was a kid, I was like opening computers to look at the mainframe and to understand how everything is working, although I didn't even understand anything about it, but it was just interesting. And that's kind of what we will do today. I will lift the lid and we will look into the internals of Fabric. And we're going to come across a lot of different technical terms and buzzwords, but I hope to try to make sense out of it for you guys. Okay, so let's get into it. Like this is the starting point of a very simple React Native app. We have our app function component that contains some other composite component. And then at the bottom we have something called the app registry, which probably most of you are familiar with, where we are passing our app function. So good so far. Now, the only problem with that code is that it's JavaScript that gets executed by a JavaScript runtime. And the JavaScript runtime doesn't understand JSX like those angle brackets thing. So we need to transform that somehow and as you might know we use Bubble for that. And Bubble is changing the React stuff to actual function calls. So we can see that we have now those JSX function in our code. Now what is the result or the return type when we call our app function that will call the JSX function? The answer is very simple. We just get a JavaScript object. We refer to these JavaScript objects as React elements. Now, there's one important point that I want to make here. We can see that we have at the top level our view from our app, and we can see that we have the props where we have the styles from our view, but we also have the children and in the children we see that there is the type function and then there is composite components. So at this point we didn't like create a full tree of react elements or anything, but we really just got one element for our function component that we were calling and it basically stopped there. But what we've established is that we went from our view to a React element. Now, what are we going to do with that React element? Well, we're going to pass the React element to our React Native Renderer, which we were seeing at this App Registry call. And looking into the implementation of the app registry, we can see that we get our root component, which is basically just our function component, and then we render it, but we wrap a lot of stuff around it. And this might come off as a surprise, like in a React Native app, your app component isn't like the most root thing in the tree, but it's actually wrapped by some other stuff. Now, we create this element and then we pass it off to renderer.renderElement, which is really the place where we go into the JavaScript rendering implementation. Now, let's look quickly at what is happening there. We have the render function and then we see that we create a route by calling createContainer and then we are using updateContainer to update the route with our React element. Okay, and then we see the imports where those functions are coming from and we see react reconciler and the question is what the hell is react reconciler? so basically or very simply put the react reconciler is kind of a mechanism that we can use to enable react to run on any platform and so basically what a host platform that you want to support React Native for has to do is to implement a config that it passes to the React reconciler so that React knows how to create instances on your host platform. And here's one very simple example. So this is like a minimal implementation for how you could implement a config for rendering on the web with React. Now, there's React DOM, and this is a huge implementation, but it can actually be very simple. So you see we have those API methods that we need to implement. Most noticeably, there is create instance, where we see that we use the document web API to create an element. And very importantly here is that we are just creating an instance that we now have in memory. We haven't actually displayed it on the web page yet. We were just creating the element. And then we see here that we do stuff like append child so that we can append instances to other instances to kind of create a tree structure. So, in detail, React Reconciler is really an algorithm that takes our React element as a starting point, and then traverses through all of our components by calling the function components or the render methods. And every time it does that thing, it creates a Fiber node. So we can see that in our structure we went from our React component to a React element to now having a Fiber node. And this internal representation of Fiber Notes is what we call the Fiber Tree. And when we schedule updates in our React apps, what the React Reconciler will do is it will create a new Fiber Tree for everything of our app. But it does so very efficiently, like it will try to clone as much as possible to reduce work. But it's creating kind of a new tree. Now there's one important thing that I want to mention about using React and React Native. Because we as developers are only using the React API, like create context, the JSX, use state hooks. But there is like the React Renderer from React Native, which passes a config to the React Reconciler. And the React Reconciler isn't like executing work right away, but it's rather scheduling work on something like the React Scheduler, which has different priorities for executing tasks, and it's one of the building blocks that we need for concurrent rendering, or scheduling different updates with different priorities. So, let's look at the create instance method that we get from Fabric, right? This is for the config for the reconciler, and this is what Fabric implements on the JavaScript side. So we see that we have a React tag, so each instance in our tree gets a tag, and then we call native Fabric UI manager dot create node, and this is really the first time where we call to a native module of Fabric. So far we were just doing JavaScript, but now we're actually calling into the native stuff. And we can see that we create a public instance. This is really creating an object which we know as the ref that we get. Like this is what we as developers receive on our end and that we would call methods like dot measure or whatever. So let's look at what Fabric is doing in the native site now that we've seen that we are calling create node there. One thing just to realize is that before we were in JavaScript and we call into C++ functionality. And we do that by using JSI which enables us to call C++ functions. And importantly for the Fabric architecture, we try to share as much code as possible on the C++ layer. because this avoids duplicated code on the platform implementations, because at some point we have to tap into the platforms to render something to the screen, but we try to stay in the shared C++ layer as long as possible, so we can use the same code on Android and iOS. So if we think about our fiber nodes, if they had a shadow, we would really call them shadow nodes. And this is exactly the concept that Fabric internally in the C++ code has. It has those shadow nodes. So every time that we create a fiber node for a React host component, a React host component being like native components to the platform on the web, that's maybe like a div or an image. On React Native, it's like our view or, I don't know, our custom native component. And it's creating shadow nodes for that. And the shadow tree is like basically just the representation of all those shadow nodes. They exist only in C++ and they hold our components props and some internal C++ state that we can manage to have a different update flow, which we will come to in a moment. Now at this point, it's like, okay, we have the element tree and we have the shadow tree and we have the fiber tree and it's kind of all representing the same stuff, but in different layers of the architecture. So coming to the first phase of Fabric, we can think of it in two phases. And the first phase really is the comment phase. And what the reconciler will do is once it's done creating the FiberNodeTree and we have created the ShadowNodeTree alongside with it, is it will say, "Okay, I'm completed the tree now." And this function will get called on the native side. And what we see here is that we get the right ShadowTree for a surface ID. Now, in React Native, you can have different surfaces, but in your regular React Native app that you use as a developer, you usually only have one surface, and you don't really need to worry about it, but it's like a fact to keep in mind. Okay, so we have our shadow tree, and then we create a new root shadow node with the shadow tree nodes that we got from JavaScript, and we commit this to the shadow tree. Okay, so let's look at what happens when we commit to the shadow tree. Surprisingly, the comet function is trying to make a comet. So we can see that there is a loop where it's trying to apply our comet. Why is this logic here? The reason for that is that so far we were the whole time on the JavaScript thread, right? And we still are. We are coming from JavaScript with our React update. However, as we have a mobile app, it's possible that we schedule an update to update the background color on React and send this. But at the same time, the user is typing in a text input. And this is something that happens on the UI thread. And the UI thread wants to apply this update immediately. So it's possible that some other thread schedules or commits an update to the shadow tree before our commit gets applied. This is really central to how shadow trees are thread-safe and how you can make commits from different threads. So let's look at what try_commit does. This is really the hard part of the commit phase of the Fabric Renderer and the shadow tree. So as you can see, we get the current revision as our old revision. Now there's maybe a misconception that you have different shadow trees for your different states in your app, but it's really like in the native code, there's just one shadow tree instance, and you have different revisions to it. So we get the old revision, and then we call a Comet hook. And this is a way for third-party libraries to integrate into the Comet face. So, for example, reanimated uses that. And it's possible that we cancel out the Comet here. Okay, so we have run the Comet hooks. Now we get into an interesting part where we calculate the layout. So the shadow nodes have all our props, which contains the styles, and using the Yoga layout engine from Meta, which is also in C++, it does a lot of calculation to get for each shadow node that we have a position and size on the screen. So this is doing it here. This is also very interesting, but it is a topic on its own. Then we're checking if we're still like the latest comet or if anyone has gotten between us so far. And if not, we are promoting the new revision to the shadow tree. And once we're done with that, we basically know that our comet was kind of successful and we emit the layout events to all nodes that have changed. And this is what you might be thinking it is. This is calling, for example, the onLayout method on our React components. And noticeably here, so far we haven't rendered anything to the screen yet, right? We were just doing some C++ stuff. But this onLayout effect gets already called. And finally, once we're done with that, we get into the second phase of the fabric rendering, which is the mount phase. Now, the mount phase is really where we finally get to the point where we render something to the screen. So let's look at what the mount function does. We can see that we have something called the mounting coordinator, to which we set our revision that we want to mount now. And then we tell a delegate that we're done with our transaction and would like to run the transaction now. Now, this is the point where we will leave the shared C++ code and go into the platform-specific code. So the delegate really reaches into the Android and iOS implementations. In this talk, I'm going to focus on the iOS implementation, but like the Android thing, run similar to that. And you can see here that we get something called the mounting managers on both on Android and iOS. And these are their platform implementations for like running the transaction that we want to do. And in there, there's a important phase where we will check if we are on the UI thread. Because when we want to render something to the mobile screen, we have to be on the UI thread. And this is the first moment where we check if we are. And if we're not, we're making a thread hop. And here we have hopping from the JavaScript thread to the UI thread to run the transaction. Now, looking at what we're doing in perform transaction, we're using this mounting coordinator to which we were setting the revision and called pull transactions. And this will give us a transaction from which we can get mutations. And these two lines of code look maybe harmless, but internally, there's happening a lot. Because in the mounting coordinator, we have the currently mounted revision and the new revision we want to mount, and it's running like a different algorithm to calculate a list of mutations that we need to perform in order to update our native screen. And then we perform those instructions. And as you might have guessed, it's just a basic for loop where we go over each mutation and work on it. And one mutation type that we have here is the create mutation. Now, what we will get here is we will get like the view name that we want to create, for example, RCT image, RCT view, whatever. and get the shadow view for that. And then we are creating an actual component view for that. Now, these are the last few technical terms you have to learn about the Fabric Renderer. But yeah, what are component views? Component views are basically the Fabric native components. There are different ways to describe them. Component view is used all over in the native code, so I'm going to stick with that. And here's an example implementation of a native component. This is the switch component, you know, the toggle thing that we have in React. And we can see that it inherits from something called ViewComponentView, and this is like the base view implementation. And then you can see that in the init method, we are really creating this UI switch view, which is the actual native view that we want to render to the screen, and set it to the content view of our fabric view component. Now the second part to that is that the view registry needs to know how to map which component view to which shadow node. And at the bottom we have this component descriptor, and the component descriptor is really like an internal implementation detail that helps to make that connection to map like shadow nodes to the actual view components. You can maybe think of it like that. In the old architecture, we had like view managers, and in Fabric, it's more like component descriptors and view components. So this is like the new stuff in Fabric. And now here, we finally get to the point where we will render something to the screen. This is an insert mutation. and we get the view or the component descriptor that we were creating earlier in the create mutation. Then we are getting the view and get the parent view and actually mount the view to the parent. And at this moment, when we have finished running this code, there will actually be the UI switch rendered to the screen. Now, there's a lot more to Fabric. There's like view recycling, native state updates, which I briefly touched, but I believe someone else will talk about that later, how it's handling suspense, or the React scheduler in detail. But I guess this is probably you after me showing you 50 snippets of code. So yeah, this is for a different talk. Thank you so much for listening, and thank you for all those people who helped me bring this talk alive.

Mo

Thank you very much. Would you like to join us for the Q&A? Sure. So, I'm sure everyone has questions about Interpol. It's confidential. I can't, like, you know. Come on. Give us a little bit. Cool. Get your questions into the live Q&A and we can get started. Well, people are quick, so let's just jump straight into it, I guess. Can we make updates to React Native Elements from the native layer? If so, how does that work?

Hanno

Yeah, we can. This is the thing that I briefly mentioned with the text input, where you're really getting an update from your native platform that's saying, okay, the text has changed and we want to schedule or make this update to our view hierarchy. One way to do this is in the shadow nodes that we have for our views, they have this internal C++ state. And you can, from your native code, update the C++ state. And this will cause an update to the shadow node. And this will trigger a new commit to the shadow tree with your update.

Mo

And what would be some reasonable use cases for that in practice? I'm presuming this is more library maintainers will do something like this rather than an app developer.

Hanno

Exactly. An app developer doesn't touch that, really. That's happening on the library author's side.

Mo

Cool. What happens if try commit fails? Is that even a possibility? And do we just lose a commit? Or what happens?

Hanno

Yeah, there is the possibility that the comment will fail. Actually, that's an interesting question. There was a React Native assert, so that will probably fail in dev mode. But yeah, it's happening. It's possible that a comment doesn't get applied, I guess. we would need to set up a little demo and try to break it to find out how that would be possible but I guess it's possible you know where to find them in the after party just whip out your laptop and try to break it, why not

Mo

What is a surface exactly and in which cases would you need two or more?

Hanno

Yeah, so a surface is really a window that we create where we have our own React instance in there running. So imagine you're like Meta and you have a super huge product and you have different separated product teams. And you have your app which is basically native code. And now you have two features where you want to add React code to run code. And then you would probably create two surfaces from the native code to set up those windows into React, and then you can run your React code on that. Again, when you create a new React Native app, it's just one surface.

Mo

And is it still one surface when you're using something like React Native Screens or React Navigation plus React Native Screens?

Hanno

Yeah, that's an interesting thing. So with React Navigation, I believe it's still just one. One surface and they're basically like getting the native view hierarchy for the screen that you want to mount and move it to the right place. But it's still one surface. But for example, there's react native navigation which does it differently and I believe they're creating the Wix one the Wix one Yes, and they're creating a surface for every screen I believe last time I checked and this is the reason why you can't use context in react native navigation Across screens because each surface is really running its own react instance with the React reconciler creating a route.

Mo

And I guess when we're looking at the different strategies for how many surfaces you render, obviously I think brownfield apps is where you might render more than one surface. I don't know if anyone actually still uses React Native Navigation these days. Maybe they do. They're updating the repository, so.

Hanno

Are they? Okay, well, fair enough.

Mo

What's the, is there, I'm presuming there is, but is there a massive performance overhead when you start to mount more and more surfaces? I guess you can share engines, but...

Hanno

I think on the surfaces, not too much. What I think from performance perspective is interesting, that we have to react reconciler calling into our native UI manager, right? These are JSI function calls, so it's pretty optimized, but if you have a huge React Native app with lots of React components, it's easily thousands of transactions. And I haven't measured it, but I guess there is some JSI overhead, and there might be some interesting ways to see how to overcome any overhead like that.

Mo

Makes a lot of sense. More stepping out for a second and looking at the React Native development day-to-day as an app developer, With what you know now about Fabric, and the stuff that you've learned over the last few months and years, how has that changed the way that you write the React Native code itself, so the JavaScript code that you're writing?

Hanno

Yeah, I think for us React Native developers in our day-to-day jobs, honestly, it hasn't changed that much. I mean, the big change of that architecture was to enable stuff like concurrent rendering. And I feel like in the web, this has already landed quite well, but it's taking its time to get into the React Native landscape. But yeah, once we're using more the React concurrent rendering APIs, suspense stuff, we will see this a lot more, I guess. And I mean, React Native screens does that by using a suspense component. So they're doing it already under the hood, but we have app developers don't really have to care about that.

Mo

Is view flattening still a thing? I've heard that Yoga could do it to improve view performance.

Hanno

Oh yeah, view flattening is still a thing. I believe it's happening in this comment phase that I showed. I'm not 100% certain. I would need to check the code. And maybe there are some optimizations that Yoga do. But again, Yoga is its own library, a lot of C++ code. And yeah, this would be-- we would need to check. I don't know.

Mo

Did you, whilst you were learning these concepts yourself, did you do it while you were investigating view support for Nitro modules, or what brought this about?

Hanno

Yeah, right. View support for Nitro modules, which is our library for writing view components or native modules from Marginal. Yeah, part of that, and then also client issues when migrating to the new architecture and stuff not working, stuff breaking, and then understanding why it's breaking.

Mo

Makes sense. And then lastly, why are the React Native Render scheduler in the React repo as opposed to the React Native one?

Hanno

Oh yeah, so that's a funny thing. So the JavaScript code for the React Native Renderer is in the React Mono repository, not in the React Native repository, kind of. So in the React repository, there are the files separately very nicely, but then on React Native what they do is they get all of those files from the React repository and merge it into one giant file and this is I believe containing like the renderer implementation but also the reconciler and a lot of different stuff it needs at runtime. So basically for React Native it's just one JavaScript file with 16,000 lines of code that they need to include to have everything they need from the React runtime.

Mo

Makes a lot of sense. Awesome. Well, thank you so much for your talk. Really appreciate it. Give him a massive round of applause. Thank you.

Edit on GitHub