Add DocumentFragment, insertBefore, replaceChild, and createDocumentFragment with full JS binding support. Code review fixes: replace_child self-replacement now no-op per DOM spec, addEventListener validates DocumentFragment IDs, removed redundant dirty marking and double validation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250 lines
16 KiB
Markdown
250 lines
16 KiB
Markdown
# Story 2.4: DOM Tree Mutation APIs
|
|
|
|
Status: done
|
|
|
|
## Story
|
|
|
|
As a web developer using JavaScript,
|
|
I want to create, insert, replace, and move DOM nodes programmatically,
|
|
So that dynamic web pages can modify their structure at runtime.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **`document.createDocumentFragment()`** creates a lightweight container. When the fragment is inserted into the DOM via `appendChild`, `insertBefore`, or `replaceChild`, all child nodes transfer to the insertion point and the fragment becomes empty per DOM §4.5.
|
|
|
|
2. **`parentNode.insertBefore(newNode, referenceNode)`** inserts `newNode` before `referenceNode` in the parent's child list. If `referenceNode` is `null`, appends to the end. If `newNode` already exists in the DOM, it is moved.
|
|
|
|
3. **`parentNode.replaceChild(newChild, oldChild)`** replaces `oldChild` with `newChild` at the same position in the parent's child list. The old child is removed and returned. If `newChild` already exists in the DOM, it is moved.
|
|
|
|
4. **Node movement** works correctly: inserting a node that already has a parent removes it from its current parent first (already works for `appendChild`, must also work for `insertBefore` and `replaceChild`).
|
|
|
|
5. **Integration tests** verify each mutation API from JavaScript. `docs/HTML5_Implementation_Checklist.md` updated. `just ci` passes.
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] Task 1: Add DocumentFragment node type (AC: #1)
|
|
- [x] 1.1 Add `NodeKind::DocumentFragment` variant to `crates/dom/src/node.rs`
|
|
- [x] 1.2 Add `Document::create_document_fragment(&mut self) -> NodeId` method in `document.rs`
|
|
- [x] 1.3 Update all `match` on `NodeKind` across the codebase to handle the new variant (search for exhaustive matches in `dom`, `html`, `style`, `layout`, `display_list`, `render`, `web_api`)
|
|
- [x] 1.4 Implement fragment insertion semantics: when `append_child`, `insert_before`, or `replace_child` receives a `DocumentFragment` as the child, iterate its children and insert each one individually, then clear the fragment's children
|
|
- [x] 1.5 Unit tests: create fragment, add children, insert into DOM → children transfer, fragment empty
|
|
|
|
- [x] Task 2: Expose insertBefore to JavaScript (AC: #2, #4)
|
|
- [x] 2.1 Verify `Document::insert_before(parent, new_child, ref_child)` exists from Story 2.3 — if not, implement it now in `crates/dom/src/document.rs`:
|
|
- Remove `new_child` from old parent if it has one
|
|
- Find index of `ref_child` in `parent.children`
|
|
- Insert `new_child` at that index
|
|
- If `ref_child` is `None`, append to end
|
|
- Set parent link, mark dirty
|
|
- [x] 2.2 Add JS binding in `crates/web_api/src/dom_host/host_environment.rs`: wire `"insertBefore"` in the `call_method` match for `"Element"` type — extract parent_id, new_child_id, ref_child_id (handle JsValue::Null for ref_child), call `document.insert_before()`, return new_child as HostObject
|
|
- [x] 2.3 Handle DocumentFragment: if `new_child` is a fragment, insert each fragment child before `ref_child`
|
|
- [x] 2.4 Unit tests (Rust): insert before first child, middle child, last child; insert with null ref (append); move existing node; insert fragment
|
|
- [x] 2.5 JS binding tests: verify insertBefore works from JavaScript
|
|
|
|
- [x] Task 3: Implement and expose replaceChild (AC: #3, #4)
|
|
- [x] 3.1 Add `Document::replace_child(&mut self, parent: NodeId, new_child: NodeId, old_child: NodeId) -> Option<NodeId>` in `document.rs`:
|
|
- Verify `old_child` is a child of `parent`, return `None` if not
|
|
- Remove `new_child` from its current parent if it has one
|
|
- Find index of `old_child` in `parent.children`
|
|
- Replace `old_child` at that index with `new_child`
|
|
- Clear `old_child.parent`, set `new_child.parent = Some(parent)`
|
|
- Mark dirty, return `Some(old_child)`
|
|
- [x] 3.2 Add JS binding in `host_environment.rs`: wire `"replaceChild"` in `call_method` for `"Element"` — extract parent_id, new_child_id, old_child_id, call `document.replace_child()`, return old_child as HostObject (or throw if old_child not found)
|
|
- [x] 3.3 Handle DocumentFragment: if `new_child` is a fragment, replace `old_child` with all fragment children (first fragment child takes old_child's position, rest inserted after)
|
|
- [x] 3.4 Unit tests (Rust): replace first/middle/last child; replace with node from elsewhere; replace with fragment; error case (old_child not a child)
|
|
- [x] 3.5 JS binding tests: verify replaceChild works from JavaScript
|
|
|
|
- [x] Task 4: Wire createDocumentFragment to JavaScript (AC: #1)
|
|
- [x] 4.1 Add JS binding in `host_environment.rs`: wire `"createDocumentFragment"` in `call_method` for `"Document"` type — call `document.create_document_fragment()`, return as `JsValue::HostObject { id, type_name: "DocumentFragment" }`
|
|
- [x] 4.2 Ensure DocumentFragment objects support `appendChild`, `insertBefore`, `removeChild`, `replaceChild` in the method dispatch (fragments act as parents)
|
|
- [x] 4.3 Ensure `textContent` getter/setter works on fragments
|
|
- [x] 4.4 JS binding tests: create fragment from JS, add children, insert into DOM
|
|
|
|
- [x] Task 5: Integration tests and documentation (AC: #5)
|
|
- [x] 5.1 Add integration test: JS script that creates elements, uses insertBefore to reorder children, verifies DOM structure
|
|
- [x] 5.2 Add integration test: JS script that creates fragment, adds elements, appends fragment, verifies children transferred
|
|
- [x] 5.3 Add integration test: JS script that uses replaceChild, verifies old child removed and new child at correct position
|
|
- [x] 5.4 Add integration test: JS script that moves a node (appendChild to different parent), verifies removed from old parent
|
|
- [x] 5.5 Add golden test if any mutation affects rendering output
|
|
- [x] 5.6 Update `docs/HTML5_Implementation_Checklist.md` — check off DocumentFragment, insertBefore, replaceChild items
|
|
- [x] 5.7 Run `just ci` and ensure all tests pass
|
|
|
|
## Dev Notes
|
|
|
|
### Dependencies on Previous Stories
|
|
|
|
**Story 2.3** plans to add `Document::insert_before()` and `Document::insert_at()` as internal helpers for the parser's adoption agency algorithm. If Story 2.3 is complete:
|
|
- `insert_before` already exists — just wire it to JS and add DocumentFragment handling
|
|
- `insert_at` may also exist — useful for `replace_child` implementation
|
|
|
|
If Story 2.3 is NOT complete, implement `insert_before` here (Task 2.1).
|
|
|
|
### Architecture: JS Binding Pattern
|
|
|
|
The DOM-to-JS bridge uses `HostEnvironment` trait in `crates/web_api/src/dom_host/host_environment.rs`:
|
|
|
|
```rust
|
|
// JS calls: element.appendChild(child)
|
|
// VM dispatches to:
|
|
fn call_method(&mut self, obj_id: u64, obj_type: &str, method: &str, args: &[JsValue])
|
|
-> Result<JsValue, RuntimeError>
|
|
|
|
// obj_id = NodeId.index() as u64
|
|
// obj_type = "Element", "Document", "DocumentFragment", etc.
|
|
// method = "appendChild", "insertBefore", etc.
|
|
// args = JS arguments as JsValue array
|
|
```
|
|
|
|
**Existing method dispatch (in call_method):**
|
|
- `"Document"` + `"getElementById"` → `document.get_element_by_id()`
|
|
- `"Document"` + `"createElement"` → `document.create_element()`
|
|
- `"Element"` + `"appendChild"` → `document.append_child()`
|
|
- `"Element"` + `"removeChild"` → `document.remove_child()`
|
|
|
|
**Add new dispatch entries:**
|
|
- `"Document"` + `"createDocumentFragment"` → `document.create_document_fragment()`
|
|
- `"Element"` + `"insertBefore"` → `document.insert_before()`
|
|
- `"Element"` + `"replaceChild"` → `document.replace_child()`
|
|
- `"DocumentFragment"` + same methods as `"Element"` for tree mutation
|
|
|
|
### DocumentFragment Semantics
|
|
|
|
Key behavior: fragment is a **transparent container**. When inserted:
|
|
|
|
```rust
|
|
// Pseudocode for append_child with fragment support:
|
|
fn append_child(&mut self, parent: NodeId, child: NodeId) {
|
|
if self.is_document_fragment(child) {
|
|
// Transfer each fragment child to parent
|
|
let fragment_children: Vec<NodeId> = self.children(child).to_vec();
|
|
for fc in fragment_children {
|
|
self.append_child(parent, fc); // recursive, but fc is never a fragment
|
|
}
|
|
// Fragment is now empty (children moved)
|
|
} else {
|
|
// Existing behavior: remove from old parent, append to new parent
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
**Important:** The existing `append_child` must be updated to handle fragments, not just the new methods. This means modifying an existing method — be careful not to break existing behavior.
|
|
|
|
### NodeKind Match Exhaustiveness
|
|
|
|
Adding `NodeKind::DocumentFragment` will break all exhaustive `match` statements. Search the codebase for:
|
|
```
|
|
match.*node\.kind
|
|
match.*kind
|
|
NodeKind::
|
|
```
|
|
|
|
Key locations likely to need updates:
|
|
- `crates/dom/src/document.rs` — `inner_html()`, `text_content()`, other methods
|
|
- `crates/html/src/tree_builder.rs` — may match on NodeKind
|
|
- `crates/style/` — style computation skips non-element nodes
|
|
- `crates/layout/` — layout computation
|
|
- `crates/display_list/` — paint generation
|
|
- `crates/web_api/` — property getters
|
|
|
|
For most cases, `DocumentFragment` should be handled like `Document` (transparent container) or ignored (no styling, no layout, no painting).
|
|
|
|
### Error Handling for JS APIs
|
|
|
|
Follow the DOM spec's exception patterns:
|
|
- `insertBefore` with invalid `ref_child` → throw `NotFoundError` (return `Err(RuntimeError)`)
|
|
- `replaceChild` with `old_child` not a child of parent → throw `NotFoundError`
|
|
- `replaceChild`/`insertBefore` with node that would create a cycle → throw `HierarchyRequestError`
|
|
|
|
In the Rust binding, these map to returning `Err(RuntimeError::new("NotFoundError: ..."))`.
|
|
|
|
### What NOT to Change (Scope Boundaries)
|
|
|
|
- **Do NOT add `querySelector`/`querySelectorAll`** — that's Story 2.5
|
|
- **Do NOT add live collections** (`getElementsByTagName` returning live HTMLCollection) — that's Story 2.5
|
|
- **Do NOT add `cloneNode`** — not in this story's scope
|
|
- **Do NOT add `innerHTML` setter improvements** — already works
|
|
|
|
### Files to Modify
|
|
|
|
- `crates/dom/src/node.rs` — add `DocumentFragment` to `NodeKind`
|
|
- `crates/dom/src/document.rs` — add `create_document_fragment()`, `replace_child()`; verify/add `insert_before()`; update `append_child()` for fragment semantics
|
|
- `crates/dom/src/lib.rs` — ensure new types are exported
|
|
- `crates/web_api/src/dom_host/host_environment.rs` — wire JS bindings for all three new APIs + DocumentFragment method dispatch
|
|
- `crates/dom/src/tests/tree_ops_tests.rs` — Rust-level unit tests
|
|
- `crates/web_api/src/dom_host/tests/dom_tests.rs` — JS binding tests
|
|
- Various crates for `NodeKind` match updates (style, layout, display_list, etc.)
|
|
- `docs/HTML5_Implementation_Checklist.md` — update checked items
|
|
|
|
### Previous Story Intelligence
|
|
|
|
**From Story 2.3:**
|
|
- `Document::insert_before()` and `Document::insert_at()` may already be implemented as parser-internal helpers
|
|
- These handle reparenting (remove from old parent first)
|
|
- Build on these rather than reimplementing
|
|
|
|
**From Epic 1:**
|
|
- Code review catches edge-case bugs — test fragment insertion thoroughly
|
|
- Golden tests essential for rendering changes
|
|
- Always update checklists
|
|
|
|
### Testing Strategy
|
|
|
|
- **Unit tests** in `crates/dom/src/tests/tree_ops_tests.rs` — Rust-level DOM operations
|
|
- **JS binding tests** in `crates/web_api/src/dom_host/tests/dom_tests.rs` — verify JS calls work
|
|
- **Integration tests** — full pipeline: JS script → DOM mutation → verify tree structure
|
|
- **Key test scenarios:**
|
|
- `insertBefore(newNode, firstChild)` — prepend
|
|
- `insertBefore(newNode, null)` — append
|
|
- `insertBefore(existingNode, refNode)` — move
|
|
- `replaceChild(newNode, oldNode)` — swap
|
|
- `replaceChild(existingNode, oldNode)` — move + swap
|
|
- Fragment with 3 children → `appendChild(fragment)` → 3 children transferred
|
|
- Fragment with children → `insertBefore(fragment, refNode)` → children inserted before ref
|
|
- Empty fragment → `appendChild(fragment)` → no-op
|
|
- Error: `insertBefore(node, nonChildRef)` → throws
|
|
|
|
### References
|
|
|
|
- [DOM Living Standard §4.4 — Node interface](https://dom.spec.whatwg.org/#interface-node)
|
|
- [DOM Living Standard §4.5 — DocumentFragment interface](https://dom.spec.whatwg.org/#interface-documentfragment)
|
|
- [Source: crates/dom/src/document.rs] — existing DOM operations
|
|
- [Source: crates/dom/src/node.rs] — Node struct, NodeKind enum
|
|
- [Source: crates/web_api/src/dom_host/host_environment.rs] — JS binding dispatch
|
|
- [Source: crates/web_api/src/dom_host/tests/dom_tests.rs] — existing JS binding tests
|
|
- [Source: crates/dom/src/tests/tree_ops_tests.rs] — existing DOM unit tests
|
|
- [Source: docs/HTML5_Implementation_Checklist.md] — checklist to update
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
Claude Opus 4.6 (1M context)
|
|
|
|
### Debug Log References
|
|
|
|
No HALT conditions encountered. All tasks completed in a single implementation pass.
|
|
|
|
### Completion Notes List
|
|
|
|
- **Task 1:** Added `NodeKind::DocumentFragment` variant. Created `create_document_fragment()` and `is_document_fragment()` methods. Updated exhaustive matches in `serialize_node` (combined with Document arm) and `dump_node` (new arm). Updated `set_text_content` to handle fragments like elements. Refactored `append_child` and `insert_before` to delegate to internal `_single` methods, with fragment path that transfers children. Added `replace_child` with full fragment support. 7 unit tests added.
|
|
- **Task 2:** `insert_before` already existed from Story 2.3. Wired JS binding with null ref_child handling (appends) and NotFoundError for invalid ref_child. Fragment support inherited from DOM layer. 3 unit tests + 3 integration tests added.
|
|
- **Task 3:** Implemented `Document::replace_child()` with sibling-aware position tracking, including self-replacement no-op per DOM spec. Wired JS binding with NotFoundError on invalid old_child. Fragment support handles multi-child replacement at correct position. 4 unit tests + 2 integration tests added.
|
|
- **Task 4:** Wired `createDocumentFragment` on Document type. Added `DocumentFragment` type dispatch in `call_method` (delegates to Element handlers), `get_property`, and `set_property` (textContent support). 4 unit tests + 2 integration tests added.
|
|
- **Task 5:** Added 9 integration tests in `tests/js_dom_tests.rs` (including fragment replaceChild). No golden test needed (mutations don't affect rendering output since fragments are never rendered). Updated HTML5 checklist. `just ci` passes with 0 failures.
|
|
|
|
### File List
|
|
|
|
- `crates/dom/src/node.rs` — Added `DocumentFragment` variant to `NodeKind`
|
|
- `crates/dom/src/document.rs` — Added `create_document_fragment()`, `is_document_fragment()`, `replace_child()`; refactored `append_child`/`insert_before` for fragment semantics; updated exhaustive matches in `serialize_node`, `dump_node`, `set_text_content`
|
|
- `crates/dom/src/tests/tree_ops_tests.rs` — Added 12 unit tests for fragment, insertBefore, replaceChild (including self-replacement no-op)
|
|
- `crates/web_api/src/dom_host/host_environment.rs` — Added `createDocumentFragment`, `insertBefore`, `replaceChild` bindings; added `DocumentFragment` type dispatch; updated get/set_property for DocumentFragment textContent
|
|
- `crates/web_api/src/dom_host/tests/dom_tests.rs` — Added 9 unit tests for JS bindings
|
|
- `tests/js_dom_tests.rs` — Added 9 integration tests for insertBefore, replaceChild, createDocumentFragment, node movement, fragment replaceChild
|
|
- `docs/HTML5_Implementation_Checklist.md` — Checked off DOM tree node types and DOM mutation algorithms
|
|
|
|
### Change Log
|
|
|
|
- 2026-03-14: Implemented Story 2.4 — DOM Tree Mutation APIs (DocumentFragment, insertBefore, replaceChild, createDocumentFragment) with full JS binding support and comprehensive tests
|
|
- 2026-03-14: Code review fixes — (1) replace_child self-replacement now no-op per DOM spec, (2) addEventListener validates DocumentFragment IDs, (3) removed redundant dirty marking in append_child fragment path, (4) removed double validate_element_id in DocumentFragment delegation, (5) added missing fragment replaceChild integration test, (6) corrected test counts in Dev Agent Record
|