Implement React v18 from Scratch Using WASM and Rust - [9] Unit Test with Jest
Tags: react, webassembly, rust
Published: 2024-04-29 02:42:11


> Based on [big-react](https://github.com/BetaSu/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：[v9](https://github.com/ParadeTo/big-react-wasm/tree/v9)

A mature and stable ~~man~~ library like `big-react-wasm` definitely needs unit tests. So, in this article, we will pause the feature development for now and add unit tests to `big-react-wasm`. The goal this time is to run the 17 test cases provided by the `react` official documentation for `ReactElement`.

Since the test case code runs in a Node environment, we need to modify our build output. First, let's add a new script:

```js
"build:test": "node scripts/build.js --test",
```

Next, let's add handling for `--test` in our `build.js` file. There are two main points to consider. First, we need to change the output target of `wasm-pack` to `nodejs`:

```js
execSync(
  `wasm-pack build packages/react --out-dir ${cwd}/dist/react --out-name jsx-dev-runtime ${
    isTest ? '--target nodejs' : ''
  }`
)
```

In `react-dom/index.js`, the statement that imports `updateDispatcher` from `react` needs to be changed to the `commonjs` format:

```js
isTest
  ? 'const {updateDispatcher} = require("react");\n'
  : 'import {updateDispatcher} from "react";\n'
```

After setting up the Jest environment, we'll copy the `ReactElement-test.js` file from `big-react` and modify the module import paths:

```js
// ReactElement-test.js
React = require('../../dist/react')
ReactDOM = require('../../dist/react-dom')
ReactTestUtils = require('../utils/test-utils')

// test-utils.js
const ReactDOM = require('../../dist/react-dom')

exports.renderIntoDocument = (element) => {
  const div = document.createElement('div')
  return ReactDOM.createRoot(div).render(element)
}
```

When executing `jest`, you may notice that several test cases fail due to the following issues:

- Type of `REACT_ELEMENT_TYPE`

Since `REACT_ELEMENT_TYPE` in `big-react-wasm` is of type string, we need to modify these test cases accordingly:

```js
it('uses the fallback value when in an environment without Symbol', () => {
  expect((<div />).$$typeof).toBe('react.element')
})
```

This difference will also affect the execution of the following test case:

```js
const jsonElement = JSON.stringify(React.createElement('div'))
expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false)
```

The reason is that the normal value of `$$typeof` is of type `Symbol`. Therefore, when `ReactElement` is `JSON.stringify`-ed, this property gets removed. In `React.isValidElement`, it checks whether `$$typeof` is equal to `REACT_ELEMENT_TYPE`, resulting in `false` as the output. However, in `big-react-wasm`, `REACT_ELEMENT_TYPE` is a string, so the result is `true`.

Why not change it to `Symbol` then? Well, Rust has many restrictions in place to ensure thread safety, making it cumbersome to define a constant of type `Symbol`. Let me provide an example given by ChatGPT to illustrate this:

```rust
use wasm_bindgen::prelude::*;
use js_sys::Symbol;
use std::sync::Mutex;

pub static REACT_ELEMENT_TYPE: Mutex<Option<Symbol>> = Mutex::new(None);

// Somewhere in your initialization code, you would set the symbol:
fn initialize() {
    let mut symbol = REACT_ELEMENT_TYPE.lock().unwrap();
    *symbol = Some(Symbol::for_("react.element"));
}

// And when you need to use the symbol, you would lock the Mutex to safely access it:
fn use_symbol() {
    let symbol = REACT_ELEMENT_TYPE.lock().unwrap();
    if let Some(ref symbol) = *symbol {
        // Use the symbol here
    }
}

```

- Object without a prototype

The following test case creates an object without a prototype using `Object.create`. In JavaScript, it is possible to iterate over the keys of this object.

However, when calling `config.dyn_ref::<Object>()` to convert it to an `Object` in Rust, it returns `None`. But when calling `config.is_object()`, the result is indeed `true`.

```js
it('does not fail if config has no prototype', () => {
  const config = Object.create(null, {foo: {value: 1, enumerable: true}})
  const element = React.createElement(ComponentFC, config)
  console.log(element)
  expect(element.props.foo).toBe(1)
})
```

So, for this situation, we can simply use the original `config` as the `props`:

```js
Reflect::set(&react_element, &"props".into(), &config).expect("props panic");
```

- `react-dom` Host Config

In the original implementation of `react-dom`'s `HostConfig`, an error occurs if the `window` object does not exist:

```js
fn create_text_instance(&self, content: String) -> Rc<dyn Any> {
    let window = window().expect("no global `window` exists");
    let document = window.document().expect("should have a document on window");
    Rc::new(Node::from(document.create_text_node(content.as_str())))
}
```

So, we need to make some modifications:

```rust
fn create_text_instance(&self, content: String) -> Rc<dyn Any> {
    match window() {
        None => {
            log!("no global `window` exists");
            Rc::new(())
        }
        Some(window) => {
            let document = window.document().expect("should have a document on window");
            Rc::new(Node::from(document.create_text_node(content.as_str())))
        }
    }
}
```

But wait, why doesn't big-react throw an error? It's because big-react specifies the testing environment as `jsdom`. According to the official documentation, `jsdom` is a pure JavaScript implementation of the web standards, specifically designed for Node.js, including the WHATWG DOM and HTML standards.

```js
module.exports = {
  testEnvironment: 'jsdom',
}
```

If that's the case, why doesn't `big-react-wasm` work in the same `jsdom` environment? After studying the source code, I found that when `window()` is called, it actually executes the following code:

```rust
js_sys::global().dyn_into::<Window>().ok()
```

In the code snippet, when `dyn_into::<Window>()` is called, it uses `instanceof` to check if the current object is a `Window`. Could this be the reason? Let's experiment by adding a code snippet like this to the test cases:

```js
console.log(window instanceof Window)
```

The result is `false`, surprisingly. It seems to be a bug in `jsdom`. Let's search on GitHub and indeed, we found an [issue](https://github.com/jsdom/jsdom/issues/2740) related to this. Moreover, someone has already provided a solution:

```js
// jest-config.js
module.exports = {
  setupFilesAfterEnv: ['<rootDir>/setup-jest.js'],
}

// setup-jest.js
Object.setPrototypeOf(window, Window.prototype)
```

Let's add that solution and revert the Host Config back to its original state.

With these changes, all 17 test cases pass successfully.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1cxdfi2zh08x6a2tl7n3.png)


*Please kindly give me a star!*