Skip to content

fix: pair unkeyed children front-to-front during reconciliation#4099

Merged
Madoshakalaka merged 8 commits intomasterfrom
fix/unkeyed-reconciliation-front-pairing
Apr 9, 2026
Merged

fix: pair unkeyed children front-to-front during reconciliation#4099
Madoshakalaka merged 8 commits intomasterfrom
fix/unkeyed-reconciliation-front-pairing

Conversation

@Madoshakalaka
Copy link
Copy Markdown
Member

@Madoshakalaka Madoshakalaka commented Apr 2, 2026

Description

BList::apply_unkeyed stored children in reverse render order (rev_children) and paired old/new nodes from the back. When the child count changed between renders the pairing was wrong: growing a list by one at the tail caused the first child to be destroyed and recreated, while shrinking removed the first child instead of the last.

This destroyed stateful DOM elements (<video>, <input>) even though they never changed, and is the root cause behind the {for expr}, {vec}, and {option} expression flattening regressions reported in #3262.

The fix flips rev_children to render order, drains/adds excess from the tail, patches paired nodes front-to-front, then flips back. The only added cost is three in-memory Vec::reverse calls (pointer swaps, sub-microsecond for typical child counts) per reconciliation; DOM operation count is unchanged.

What this fixes

  • Static siblings before a dynamic-length list are no longer recreated when the list grows or shrinks.
  • {for iter}, {vec}, and {option} expressions that flatten into a parent VList no longer corrupt leading siblings.
  • Matches the front-to-front positional reconciliation behavior of React.

What this does not fix

  • {for expr} / {vec} / {option} still flatten into the parent (macro-level issue, separate fix).
  • Dynamic-length lists in the middle of siblings can still mismatch trailing nodes (same limitation as React; use keys or the for x in iter { } syntax for full correctness).

Fixes #3262

Checklist

  • I have reviewed my own code
  • I have added tests
    • Five new wasm_bindgen_test node-identity tests (node_identity_tests in blist.rs) that assert DOM node reuse via Node::is_same_node. Each test fails before the fix and passes after:
      • for_iterable_preserves_sibling_identity
      • vec_expression_preserves_sibling_identity
      • option_expression_preserves_sibling_identity
      • unkeyed_grow_preserves_leading_nodes
      • unkeyed_shrink_preserves_leading_nodes

`apply_unkeyed` paired nodes from the back of render order, so growing
or shrinking a list destroyed and recreated leading siblings instead of
reusing them.  Flip `rev_children` to render order before diffing,
drain/add excess from the tail, and flip back.  This makes positional
reconciliation match the intuitive front-to-front expectation and fixes
stateful elements (`<video>`, `<input>`) being reset when a sibling
list changes length.
@Madoshakalaka Madoshakalaka added performance A-yew Area: The main yew crate labels Apr 2, 2026
@Madoshakalaka Madoshakalaka changed the title fix: pair unkeyed children front-to-front during reconciliation (#3262) fix: pair unkeyed children front-to-front during reconciliation Apr 2, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Visit the preview URL for this PR (updated for commit 9aa56fc):

https://yew-rs-api--pr4099-fix-unkeyed-reconcil-8duxatdr.web.app

(expires Wed, 15 Apr 2026 18:08:57 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Benchmark - core

Yew Master

vnode           fastest       │ slowest       │ median        │ mean          │ samples │ iters
╰─ vnode_clone  2.715 ns      │ 4.156 ns      │ 2.719 ns      │ 2.773 ns      │ 100     │ 1000000000

Pull Request

vnode           fastest       │ slowest       │ median        │ mean          │ samples │ iters
╰─ vnode_clone  2.715 ns      │ 2.748 ns      │ 2.718 ns      │ 2.721 ns      │ 100     │ 1000000000

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Size Comparison

Details
examples master (KB) pull request (KB) diff (KB) diff (%)
async_clock 99.934 100.019 +0.085 +0.085%
boids 164.028 164.113 +0.085 +0.052%
communication_child_to_parent 93.363 93.448 +0.085 +0.091%
communication_grandchild_with_grandparent 105.338 105.423 +0.085 +0.081%
communication_grandparent_to_grandchild 101.696 101.781 +0.085 +0.084%
communication_parent_to_child 90.772 90.857 +0.085 +0.094%
contexts 105.582 105.667 +0.085 +0.080%
counter 85.942 86.027 +0.085 +0.099%
counter_functional 87.977 88.062 +0.085 +0.097%
dyn_create_destroy_apps 89.864 89.949 +0.085 +0.095%
file_upload 98.950 99.035 +0.085 +0.086%
function_delayed_input 93.933 94.018 +0.085 +0.090%
function_memory_game 169.588 169.673 +0.085 +0.050%
function_router 399.604 399.689 +0.085 +0.021%
function_todomvc 164.479 164.564 +0.085 +0.052%
futures 234.646 234.790 +0.144 +0.061%
game_of_life 100.275 100.360 +0.085 +0.085%
immutable 258.330 258.541 +0.211 +0.082%
inner_html 80.465 80.550 +0.085 +0.106%
js_callback 109.429 109.514 +0.085 +0.078%
keyed_list 175.888 175.973 +0.085 +0.048%
mount_point 83.833 83.918 +0.085 +0.101%
nested_list 113.025 113.110 +0.085 +0.075%
node_refs 91.350 91.435 +0.085 +0.093%
password_strength 1718.235 1718.320 +0.085 +0.005%
portals 92.836 92.921 +0.085 +0.092%
router 365.983 366.068 +0.085 +0.023%
suspense 113.537 113.622 +0.085 +0.075%
timer 88.080 88.165 +0.085 +0.096%
timer_functional 98.696 98.781 +0.085 +0.086%
todomvc 141.761 141.846 +0.085 +0.060%
two_apps 85.806 85.891 +0.085 +0.099%
web_worker_fib 135.941 136.026 +0.085 +0.062%
web_worker_prime 184.318 184.403 +0.085 +0.046%
webgl 82.605 82.690 +0.085 +0.103%

✅ None of the examples has changed their size significantly.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Benchmark - SSR

Yew Master

Details
Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 291.202 291.686 291.346 0.153
Hello World 10 499.704 522.302 505.540 7.633
Function Router 10 31386.483 32235.346 31849.391 282.368
Concurrent Task 10 1006.623 1007.916 1007.251 0.402
Many Providers 10 1104.164 1160.248 1129.927 19.620

Pull Request

Details
Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 291.168 297.220 292.349 2.186
Hello World 10 507.085 523.306 512.326 4.598
Function Router 10 31135.507 31638.343 31517.786 141.407
Concurrent Task 10 1006.332 1007.552 1006.884 0.472
Many Providers 10 1128.229 1202.005 1164.147 25.069

@Madoshakalaka
Copy link
Copy Markdown
Member Author

The benchmark deltas are CI environmental noise, not caused by this change.

05_swap1k and 06_remove-one-1k show 12-18% and 11-14% improvement respectively, but neither ever calls apply_unkeyed. Both go through the full apply_keyed path (key mismatch from the back is detected at the swap/removal point), which this PR does not touch.

For benchmarks that do hit apply_unkeyed via the keyed fast-path fallback (03_update10th1k_x16, 04_select1k), old and new lists have identical length (1000 vs 1000), so both old and new code produce identical DOM operations with the same node pairing. The only added work is three Vec::reverse calls on the 1000-element rev_children, totaling μs levels of pointer swaps against ms levels of DOM patches.

The real performance cost of this fix is those three in-memory reverses per apply_unkeyed call. For the typical case (same-length lists), this is maaybe 0.03% of reconciliation time. For the corrected case (different-length lists), the fix avoids destroying and recreating DOM elements that were previously mis-paired, which is a net performance win since creating a new element is far more expensive than reusing one.

@Madoshakalaka
Copy link
Copy Markdown
Member Author

@WorldSEnder you might be interested

@Madoshakalaka Madoshakalaka marked this pull request as ready for review April 2, 2026 07:01
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
Replace rev_children (reverse render order) with children (render order).

apply_keyed assigns reverse indices to the spliced middle so the existing
run algorithm works unchanged. shift iterates in reverse. Hydration drops
a now-unnecessary reverse.
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
@Madoshakalaka Madoshakalaka marked this pull request as draft April 2, 2026 16:47
Replace the reversed-index hack (middle_count - 1 - fwd_idx) with
native render-order indices in apply_keyed's run algorithm. The barrier
tracking uses Option<usize> so checked_sub handles the "everything
committed" edge case that previously required reverse indexing.
@Madoshakalaka Madoshakalaka marked this pull request as ready for review April 2, 2026 17:19
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
Use into_iter().rev() (pointer arithmetic on DoubleEndedIterator)
instead of in-place slice reverse (element-sized swap loops).
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
github-actions[bot]
github-actions bot previously approved these changes Apr 2, 2026
github-actions[bot]
github-actions bot previously approved these changes Apr 3, 2026
Resolve conflict in blist.rs where Edition 2024 migration (#4114)
changed `if let` to `match` while our branch restructured the inner
keyed-reconciliation logic for render-order indices. Take master's
`match ancestor` syntax with our Option<usize> barrier_idx and
flipped comparison directions.
github-actions[bot]
github-actions bot previously approved these changes Apr 5, 2026
- remove a duplicate call to `matching_len`. It makes no difference if we do this before or after already processing the tail if no keys are duplicate
- could use `v.key()` instead of `key!(v)` when matching lengths for a handful of bytes.
Copy link
Copy Markdown
Member

@WorldSEnder WorldSEnder left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Down to less than 100 bytes delta for most examples, it's the best I can do. I think the additional bytes come from a call to reverse, but I don't see a way to avoid it and the perf wins trump the code size delta.

"prepending to a keyed list should preserve trailing <e>",
);
}
}
Copy link
Copy Markdown
Member

@WorldSEnder WorldSEnder Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last comment before signing this off. I don't see the unkeyed tests as a public guarantee. They still serve as a good documentation of the current behaviour and marker for this PR and the old one stayed for years, so leave them in. If you ever find this PR and comment in the future remember that keys are the only public guarantee about node identity we make. This tests a performance modification that we think a lot of people are hitting in practice where a specific unkeyed strategy aligns better with expectations. It is NOT a semantic guarantee. Changes are okay, but should adjust the test cases.

@Madoshakalaka
Copy link
Copy Markdown
Member Author

Thanks, looking good!

@Madoshakalaka Madoshakalaka merged commit 40e7134 into master Apr 9, 2026
36 checks passed
@Madoshakalaka Madoshakalaka deleted the fix/unkeyed-reconciliation-front-pairing branch April 9, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression: elements are re-rendered even if they didn't change, when there's a list below them (worked in v0.19.3)

2 participants