DEV Community

Cover image for Implement React v18 from Scratch Using WASM and Rust - [19] Performance Optimization: bailout & eagerState
ayou
ayou

Posted on

Implement React v18 from Scratch Using WASM and Rust - [19] Performance Optimization: bailout & eagerState

Based on big-react,I am going to implement React v18 core features from scratch using WASM and Rust.

Code Repository:https://github.com/ParadeTo/big-react-wasm

The tag related to this article:v19

In React, there are two optimization strategies called "bailout" and "eagerState." Bailout refers to skipping the current FiberNode or its descendant nodes, or both, during the reconciliation process when certain conditions are met. On the other hand, eagerState allows skipping the update directly when updating the state if certain conditions are met. Below, we will provide an example to illustrate these strategies (React version 18.2.0):

function Child({num}) {
  console.log('Child render')
  return <div>Child {num}</div>
}

function Parent() {
  console.log('Parent render')
  const [num, setNum] = useState(1)
  return (
    <div onClick={() => setNum(2)}>
      Parent {num}
      <Child num={num} />
    </div>
  )
}

export default function App() {
  console.log('App render')
  return (
    <div>
      App
      <Parent />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
  1. First render, console output:
App render
Parent render
Child render
Enter fullscreen mode Exit fullscreen mode
  1. First click, console output:
Parent render
Child render
Enter fullscreen mode Exit fullscreen mode
  1. Second click, console output:
Parent render
Enter fullscreen mode Exit fullscreen mode
  1. Third click, no console output.

Let's analyze this briefly:

  • During the first render, all components are printed, which is expected.

  • During the first click, the App component doesn't have any prop changes and doesn't trigger an update task, so it's reasonable that "App render" is not printed. The Parent component triggers an update (1->2), so "Parent render" is printed, which is expected. The Child component has prop changes, so "Child render" is printed, which is also expected.

  • During the second click, although the Parent component triggers an update, the state remains the same. It seems that "Parent render" shouldn't be printed, which is the first question. Also, since the Parent component is re-executed, it implies that the ReactElements under the Parent component are also recreated, so the new and old props of the Child component should be different. Why isn't "Child render" printed? This is the second question.

  • During the third click, although the Parent component triggers an update, the state remains the same. "Parent render" shouldn't be printed, and "Child render" shouldn't be printed either, which is reasonable.

Regarding the first question, it actually reflects that React's optimization is not thorough enough. For more details, please refer to this article (article in Chinese).

Regarding the second question, it will be explained below.

Next, let's briefly introduce how to implement these two optimizations. The specific changes can be found here.

bailout

Before we proceed with the implementation, let's think about when a FiberNode can skip the reconciliation process. Upon careful consideration, it should meet the following conditions:

  • The props and type of the node haven't changed.
  • The update priority of the node is lower than the current update priority.
  • There is no use of Context.
  • The developer hasn't used shouldComponentUpdate or React.memo to skip updates.

Since our "big react wasm" implementation hasn't included the last two conditions, we will only discuss the first two conditions for now.

We need to add the bailout logic at the beginning of the begin_work function. The code is as follows:

...
unsafe {
    DID_RECEIVE_UPDATE = false;
};
let current = work_in_progress.borrow().alternate.clone();

if current.is_some() {
    let current = current.unwrap();
    let old_props = current.borrow().memoized_props.clone();
    let old_type = current.borrow()._type.clone();
    let new_props = work_in_progress.borrow().pending_props.clone();
    let new_type = work_in_progress.borrow()._type.clone();
    // Condition 1
    if !Object::is(&old_props, &new_props) || !Object::is(&old_type, &new_type) {
        unsafe { DID_RECEIVE_UPDATE = true }
    } else {
        // Condition 2
        let has_scheduled_update_or_context =
            check_scheduled_update_or_context(current.clone(), render_lane.clone());

        if !has_scheduled_update_or_context {
            unsafe { DID_RECEIVE_UPDATE = false }
            return Ok(bailout_on_already_finished_work(
                work_in_progress,
                render_lane,
            ));
        }
    }
}

work_in_progress.borrow_mut().lanes = Lane::NoLane;
...
Enter fullscreen mode Exit fullscreen mode
fn check_scheduled_update_or_context(current: Rc<RefCell<FiberNode>>, render_lane: Lane) -> bool {
    let update_lanes = current.borrow().lanes.clone();
    if include_some_lanes(update_lanes, render_lane) {
        return true;
    }
    // TODO Context
    false
}
Enter fullscreen mode Exit fullscreen mode

And when this FiberNode hits the bailout strategy, if none of the child nodes meet the update priority for this update, the entire subtree rooted at the current FiberNode can also be skipped.

fn bailout_on_already_finished_work(
    wip: Rc<RefCell<FiberNode>>,
    render_lane: Lane,
) -> Option<Rc<RefCell<FiberNode>>> {

    if !include_some_lanes(wip.borrow().child_lanes.clone(), render_lane) {
        if is_dev() {
            log!("bailout the whole subtree {:?}", wip);
        }
        return None;
    }
    if is_dev() {
        log!("bailout current fiber {:?}", wip);
    }
    clone_child_fiblers(wip.clone());
    wip.borrow().child.clone()
}
Enter fullscreen mode Exit fullscreen mode

The child_lanes here represent the combined lanes of all the descendant nodes of a FiberNode, as shown below:

Image description

When a node triggers an update, it bubbles up to update the child_lanes of its ancestors.

As mentioned earlier, the bailout strategy has three scenarios: skipping the current FiberNode, skipping the current FiberNode and its descendants, and skipping only the descendants. We have already discussed the first two scenarios, so when does the third scenario occur? The answer lies in the update_function_component:

fn update_function_component(
    work_in_progress: Rc<RefCell<FiberNode>>,
    render_lane: Lane,
) -> Result<Option<Rc<RefCell<FiberNode>>>, JsValue> {
    let next_children = render_with_hooks(work_in_progress.clone(), render_lane.clone())?;

    let current = work_in_progress.borrow().alternate.clone();
    if current.is_some() && unsafe { !DID_RECEIVE_UPDATE } {
        bailout_hook(work_in_progress.clone(), render_lane.clone());
        return Ok(bailout_on_already_finished_work(
            work_in_progress,
            render_lane,
        ));
    }

    reconcile_children(work_in_progress.clone(), Some(next_children));
    Ok(work_in_progress.clone().borrow().child.clone())
}
Enter fullscreen mode Exit fullscreen mode

Here, the component code corresponding to the current FiberNode is executed once. Only if it detects a difference between the previous and current values of a state, it sets DID_RECEIVE_UPDATE to true:

// filber_hooks.rs
...
if !Object::is(&ms_value, &ps_value) {
    mark_wip_received_update();
}
...
Enter fullscreen mode Exit fullscreen mode
// begin_work.rs
static mut DID_RECEIVE_UPDATE: bool = false;

pub fn mark_wip_received_update() {
    unsafe { DID_RECEIVE_UPDATE = true };
}

Enter fullscreen mode Exit fullscreen mode

If the values are the same, it can proceed to the logic of bailout_on_already_finished_work.

This explains the second question. Although the Parent component was unexpectedly re-rendered, this additional safeguard prevents the impact from spreading further.

eagerState

The implementation of eagerState is relatively straightforward. When dispatch_set_state is called, if both the WIP and Current have a priority of NoLane and the state values before and after the update are equal, the update can be skipped directly:

fn dispatch_set_state(
    fiber: Rc<RefCell<FiberNode>>,
    update_queue: Rc<RefCell<UpdateQueue>>,
    action: &JsValue,
) {
    let lane = request_update_lane();
    let mut update = create_update(action.clone(), lane.clone());
    let current = { fiber.borrow().alternate.clone() };

    if fiber.borrow().lanes == Lane::NoLane
        && (current.is_none() || current.unwrap().borrow().lanes == Lane::NoLane)
    {
        let current_state = update_queue.borrow().last_rendered_state.clone();
        if current_state.is_none() {
            panic!("current state is none")
        }
        let current_state = current_state.unwrap();
        let eager_state = basic_state_reducer(&current_state, &action);
        // if not ok, the update will be handled in render phase, means the error will be handled in render phase
        if eager_state.is_ok() {
            let eager_state = eager_state.unwrap();
            update.has_eager_state = true;
            update.eager_state = Some(eager_state.clone());
            if Object::is(&current_state, &eager_state) {
                enqueue_update(update_queue.clone(), update, fiber.clone(), Lane::NoLane);
                if is_dev() {
                    log!("Hit eager state")
                }
                return;
            }
        }
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

This article concludes that the reason React optimization is not thorough is because there are two FiberNode trees in React. When a click occurs, only the "update flags" on one tree are cleared, so an additional execution is needed to ensure that the "update flags" on both trees are cleared. Therefore, if an additional line of code is added here, it can achieve thorough optimization.

pub fn begin_work(
    work_in_progress: Rc<RefCell<FiberNode>>,
    render_lane: Lane,
) -> Result<Option<Rc<RefCell<FiberNode>>>, JsValue> {
...
work_in_progress.borrow_mut().lanes = Lane::NoLane;
+ if current.is_some() {
+     let current = current.clone().unwrap();
+     current.borrow_mut().lanes = Lane::NoLane;
+ }
...
}
Enter fullscreen mode Exit fullscreen mode

Please kindly give me a star!

Top comments (0)