Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Added `VirtualNode::with_key` for stable reconciliation identity across
elements, components, fragments, and text, and made browser regressions fail
on React console warnings and errors.
- Made `static_style` and `contained_static_style` safe to evaluate without a
browser `window`, enabling server-side and pre-rendered module imports.
- Added stateful MoonBit component regressions for Todo editing, creation,
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Component functions must be placed in the virtual DOM through `component`, not
called directly. Use `component_with_children` when the component needs to
place caller-supplied children in its own tree.

Apply `with_key` to children in dynamic collections, using stable application
identities rather than array indexes.

## Bound APIs and Types

### Core Rendering API
Expand All @@ -39,6 +42,7 @@ place caller-supplied children in its own tree.
- `unmount(parent: @dom.Element) -> Unit` - Unmount the React root for an element
- `component[T](f: (T) -> VirtualNode, props: T, children: Array[VirtualNode]) -> VirtualNode` - Create a leaf component
- `component_with_children[T](f: (T, Array[VirtualNode]) -> VirtualNode, props: T, children: Array[VirtualNode]) -> VirtualNode` - Create a component that places its children
- `VirtualNode::with_key(key: String) -> VirtualNode` - Assign a stable React reconciliation key without adding a DOM wrapper

For example, use `component(my_component, props, [])`, never
`my_component(props)` directly in a virtual DOM tree.
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="data:,">
<title>TodoMVC - MoonBit + React</title>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion src/main/todolist.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ fn comp_todolist(_v : TodoListProps) -> VirtualNode {
let mut i = 0
while i < filtered_todos.length() {
let todo = filtered_todos[i]
items.push(create_todo_item(todo))
items.push(create_todo_item(todo).with_key(todo.id.to_string()))
i = i + 1
}
items
Expand Down
1 change: 1 addition & 0 deletions src/pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ pub(all) enum VirtualNode {
JsNode(@dom-ffi.JsObscure)
}
pub fn VirtualNode::to_js_obscure(Self) -> @dom-ffi.JsObscure
pub fn VirtualNode::with_key(Self, String) -> Self

// Type aliases

Expand Down
45 changes: 45 additions & 0 deletions src/react.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ extern "js" fn react_fragment(
) -> @dom.JsObscure =
#| (props, children) => window.React.createElement(window.React.Fragment, props, ...children)

///|
extern "js" fn react_clone_element_with_key(
node : @dom.JsObscure,
key : String,
) -> @dom.JsObscure =
#| (node, key) => window.React.cloneElement(node, { key })

///|
extern "js" fn react_use_state(initial : JsObscure) -> JsObscure =
#| (initial) => { return window.React.useState(initial)}
Expand Down Expand Up @@ -551,6 +558,44 @@ pub fn VirtualNode::to_js_obscure(self : VirtualNode) -> @dom.JsObscure {
ret
}

///|
/// Assigns a stable React reconciliation key to a virtual node.
///
/// This works uniformly for elements, components, fragments, and text without
/// adding a DOM wrapper. Use stable application identities rather than list
/// indexes when rendering dynamic collections.
pub fn VirtualNode::with_key(self : VirtualNode, key : String) -> VirtualNode {
match self {
Element(element) => {
element.attrs.set("key", key)
Element(element)
}
Fragment(children) => {
let props = @dom.JsObjectObscure::new()
props.set("key", JsObscure::from_string(key))
JsNode(
react_fragment(
props,
FixedArray::from_array(
children.map(fn(child) { child.to_js_obscure() }),
),
),
)
}
JsNode(node) => JsNode(react_clone_element_with_key(node, key))
Text(text) => {
let props = @dom.JsObjectObscure::new()
props.set("key", JsObscure::from_string(key))
JsNode(
react_fragment(
props,
FixedArray::from_array([JsObscure::from_string(text)]),
),
)
}
}
}

///|
/// Converts legacy DOM attribute names to React-compatible property names.
/// This function provides backward compatibility for traditional HTML attribute names.
Expand Down
10 changes: 10 additions & 0 deletions src/react_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ extern "js" fn install_test_react() -> Unit =
#| Fragment: Symbol("Fragment"),
#| createElement: (tag, props, ...children) =>
#| typeof tag === "function" ? tag(props) : ({ tag, props, children }),
#| cloneElement: (node, props) => ({ ...node, props: { ...node.props, ...props } }),
#| },
#| };
#| }
Expand Down Expand Up @@ -785,6 +786,15 @@ test "fragments create React fragment nodes with every child" {
inspect(react_element_child_count(fragment), content="2")
}

///|
test "virtual nodes accept stable reconciliation keys" {
install_test_react()
let node = div([Text("keyed child")]).with_key("todo-42").to_js_obscure()
inspect(react_element_string_prop(node, "key"), content="todo-42")
inspect(react_element_child_count(node), content="1")
inspect(react_element_tag(node), content="div")
}

///|
test "attributes set through ElementAttrs are converted to React props" {
install_test_react()
Expand Down
7 changes: 7 additions & 0 deletions tests/todomvc.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { expect, test } from "@playwright/test";

test("TodoMVC supports editing, creating, filtering, and clearing todos", async ({ page }) => {
const pageErrors = [];
const consoleProblems = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (["warning", "error"].includes(message.type())) {
consoleProblems.push(`${message.type()}: ${message.text()}`);
}
});

await page.goto("/");
await expect(page.locator(".todo-list li")).toHaveCount(3);
Expand Down Expand Up @@ -41,4 +47,5 @@ test("TodoMVC supports editing, creating, filtering, and clearing todos", async
await page.getByRole("link", { name: "All", exact: true }).click();
await expect(page.locator(".todo-list li")).toHaveCount(3);
expect(pageErrors).toEqual([]);
expect(consoleProblems).toEqual([]);
});