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:v25
In the new version of React, Suspense
plays a significant role in combining with use
to fetch data. Today, let's implement it. You can find the changes in this link.
Let's explain the changes using the following example:
import { Suspense, use } from 'react'
const delay = (t) =>
new Promise((resolve, reject) => {
setTimeout(reject, t)
})
const cachePool = []
function fetchData(id, timeout) {
const cache = cachePool[id]
if (cache) {
return cache
}
return (cachePool[id] = delay(timeout).then(() => {
return { data: Math.random().toFixed(2) * 100 }
}))
}
export default function App() {
return (
<Suspense fallback={<div>loading</div>}>
<Child />
</Suspense>
)
}
function Child() {
const { data } = use(fetchData(1, 1000))
return <span>{data}</span>
}
First, we add the relevant code according to the process of adding new hooks. Finally, we arrive at fiber_hooks.rs
:
fn _use(usable: JsValue) -> Result<JsValue, JsValue> {
if !usable.is_null() && type_of(&usable, "object") {
if derive_from_js_value(&usable, "then").is_function() {
return track_used_thenable(usable);
} else if derive_from_js_value(&usable, "$typeof") == REACT_CONTEXT_TYPE {
return Ok(read_context(usable));
}
}
Err(JsValue::from_str("Not supported use arguments"))
}
From the code, we can see that the use
function can accept a Promise
object or a Context
object. Here, we only discuss the Promise
object, so let's take a look at track_used_thenable
:
#[wasm_bindgen]
extern "C" {
pub static SUSPENSE_EXCEPTION: JsValue;
}
pub fn track_used_thenable(thenable: JsValue) -> Result<JsValue, JsValue> {
...
unsafe { SUSPENDED_THENABLE = Some(thenable.clone()) };
Err(SUSPENSE_EXCEPTION.__inner.with(JsValue::clone))
}
We'll skip the middle part for now. In the end, it returns a variant of Result
, Err
, with SUSPENSE_EXCEPTION
as the payload. This SUSPENSE_EXCEPTION
is inserted into the result during the build process:
const SUSPENSE_EXCEPTION = new Error(
"It's not a true mistake, but part of Suspense's job. If you catch the error, keep throwing it out"
)
Returning SUSPENSE_EXCEPTION
instead of thenable
directly helps differentiate between exceptions thrown by user code and exceptions thrown by React. The value we are interested in is stored in SUSPENDED_THENABLE
.
Next, we come to work_loop.rs
:
loop {
...
match if should_time_slice {
work_loop_concurrent()
} else {
work_loop_sync()
} {
Ok(_) => {
break;
}
Err(e) => handle_throw(root.clone(), e),
};
}
The e
here is the SUSPENSE_EXCEPTION
mentioned earlier. Let's see how handle_throw
handles it:
fn handle_throw(root: Rc<RefCell<FiberRootNode>>, mut thrown_value: JsValue) {
/*
throw possibilities:
1. use thenable
2. error (Error Boundary)
*/
if Object::is(&thrown_value, &SUSPENSE_EXCEPTION) {
unsafe { WORK_IN_PROGRESS_SUSPENDED_REASON = SUSPENDED_ON_DATA };
thrown_value = get_suspense_thenable();
} else {
// TODO
}
unsafe {
WORK_IN_PROGRESS_THROWN_VALUE = Some(thrown_value);
}
}
Here, it checks if the exception is SUSPENSE_EXCEPTION
. If it is, the actual value is retrieved, which matches what was mentioned earlier.
This value will eventually be passed to throw_and_unwind_work_loop
:
loop {
unsafe {
if WORK_IN_PROGRESS_SUSPENDED_REASON != NOT_SUSPENDED && WORK_IN_PROGRESS.is_some() {
let thrown_value = WORK_IN_PROGRESS_THROWN_VALUE.clone().unwrap();
WORK_IN_PROGRESS_SUSPENDED_REASON = NOT_SUSPENDED;
WORK_IN_PROGRESS_THROWN_VALUE = None;
// TODO
mark_update_lane_from_fiber_to_root(
WORK_IN_PROGRESS.clone().unwrap(),
lane.clone(),
);
throw_and_unwind_work_loop(
root.clone(),
WORK_IN_PROGRESS.clone().unwrap(),
thrown_value,
lane.clone(),
);
}
}
...
}
We have already discussed this in the previous article, so we won't go into detail here. Let's go back to track_used_thenable
:
pub fn track_used_thenable(thenable: JsValue) -> Result<JsValue, JsValue> {
let status = derive_from_js_value(&thenable, "status");
if status.is_string() {
...
} else {
Reflect::set(&thenable, &"status".into(), &"pending".into());
let v = derive_from_js_value(&thenable, "then");
let then = v.dyn_ref::<Function>().unwrap();
let thenable1 = thenable.clone();
let on_resolve_closure = Closure::wrap(Box::new(move |val: JsValue| {
if derive_from_js_value(&thenable1, "status") == "pending" {
Reflect::set(&thenable1, &"status".into(), &"fulfilled".into());
Reflect::set(&thenable1, &"value".into(), &val);
}
}) as Box<dyn Fn(JsValue) -> ()>);
let on_resolve = on_resolve_closure
.as_ref()
.unchecked_ref::<Function>()
.clone();
on_resolve_closure.forget();
let thenable2 = thenable.clone();
let on_reject_closure = Closure::wrap(Box::new(move |err: JsValue| {
if derive_from_js_value(&thenable2, "status") == "pending" {
Reflect::set(&thenable2, &"status".into(), &"rejected".into());
Reflect::set(&thenable2, &"reason".into(), &err);
}
}) as Box<dyn Fn(JsValue) -> ()>);
let on_reject = on_reject_closure
.as_ref()
.unchecked_ref::<Function>()
.clone();
on_reject_closure.forget();
then.call2(&thenable, &on_resolve, &on_reject);
}
}
When entering this function for the first time, it goes to the else
branch. The core logic is to add on_resolve
and on_reject
methods to thenable
and modify its status
, value
, and reason
properties.
When the status of the Promise
object is no longer pending
, it triggers a re-render. When this function is called again, the status
will have a value, and it will enter the if
block:
if status.is_string() {
if status == "fulfilled" {
return Ok(derive_from_js_value(&thenable, "value"));
} else if status == "rejected" {
return Err(derive_from_js_value(&thenable, "reason"));
}
...
}
If the status is fulfilled
, it returns the value; otherwise, it throws the exception from the reason
field.
This is how Suspense
combines with the use
hook to fetch data. However, debugging revealed that the bailout logic affects the normal operation of this process. Therefore, for now, we have to temporarily comment out this part of the code and revisit how to solve it later when we have time.