Skip to content

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

Open
Madoshakalaka wants to merge 6 commits intomasterfrom
fix/unkeyed-reconciliation-front-pairing
Open

fix: pair unkeyed children front-to-front during reconciliation#4099
Madoshakalaka wants to merge 6 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 23cb183):

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

(expires Fri, 10 Apr 2026 06:34:59 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  4.004 ns      │ 4.15 ns       │ 4.007 ns      │ 4.01 ns       │ 100     │ 1000000000

Pull Request

vnode           fastest       │ slowest       │ median        │ mean          │ samples │ iters
╰─ vnode_clone  3.998 ns      │ 4.187 ns      │ 4.002 ns      │ 4.009 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 100.858 101.115 +0.257 +0.255%
boids 168.452 168.709 +0.257 +0.152%
communication_child_to_parent 94.075 94.332 +0.257 +0.273%
communication_grandchild_with_grandparent 105.913 106.170 +0.257 +0.242%
communication_grandparent_to_grandchild 102.260 102.517 +0.257 +0.251%
communication_parent_to_child 91.485 91.742 +0.257 +0.281%
contexts 105.973 106.229 +0.257 +0.242%
counter 86.797 87.054 +0.257 +0.296%
counter_functional 88.833 89.090 +0.257 +0.289%
dyn_create_destroy_apps 90.714 90.971 +0.257 +0.283%
file_upload 99.811 100.067 +0.257 +0.257%
function_delayed_input 94.803 95.060 +0.257 +0.271%
function_memory_game 173.666 173.922 +0.256 +0.147%
function_router 395.506 395.762 +0.256 +0.065%
function_todomvc 164.954 165.210 +0.256 +0.155%
futures 235.550 235.864 +0.314 +0.133%
game_of_life 105.098 105.354 +0.256 +0.243%
immutable 259.581 259.962 +0.381 +0.147%
inner_html 81.340 81.597 +0.257 +0.316%
js_callback 109.961 110.218 +0.257 +0.234%
keyed_list 180.407 180.664 +0.257 +0.142%
mount_point 84.714 84.971 +0.257 +0.303%
nested_list 113.659 113.915 +0.256 +0.225%
node_refs 92.087 92.344 +0.257 +0.279%
password_strength 1719.253 1719.510 +0.257 +0.015%
portals 93.560 93.816 +0.257 +0.275%
router 366.146 366.401 +0.256 +0.070%
suspense 113.966 114.223 +0.257 +0.225%
timer 88.942 89.199 +0.257 +0.289%
timer_functional 99.369 99.626 +0.257 +0.258%
todomvc 142.660 142.916 +0.256 +0.179%
two_apps 86.710 86.967 +0.257 +0.296%
web_worker_fib 136.459 136.716 +0.257 +0.188%
web_worker_prime 187.646 187.903 +0.257 +0.137%
webgl 83.486 83.743 +0.257 +0.308%

✅ 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 310.875 311.533 311.120 0.210
Hello World 10 484.870 490.974 486.223 1.762
Function Router 10 37877.151 45382.880 42220.066 2375.075
Concurrent Task 10 1006.511 1007.744 1007.262 0.430
Many Providers 10 1076.593 1120.415 1098.860 14.294

Pull Request

Details
Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 310.804 313.009 311.280 0.696
Hello World 10 471.423 483.963 477.570 5.537
Function Router 10 34686.786 39045.792 37412.037 1229.510
Concurrent Task 10 1005.608 1007.540 1006.819 0.577
Many Providers 10 1075.764 1105.085 1086.528 8.804

@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
Comment on lines +166 to +168
// `rights` is stored in reverse render order. Flip to render order so
// that simple index-based pairing matches children front-to-front.
rights.reverse();
Copy link
Copy Markdown
Member

@WorldSEnder WorldSEnder Apr 2, 2026

Choose a reason for hiding this comment

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

Does it even make sense to keep rights in reverse order if we do this?

The only reason I can recall why this is done so that no reordering or swapping of the elements in the vec needs to happen here. You need to check for yourself that the existing implementation is the only possible way to do this with Vec<_> and the constraint that the writer calls need to happen back-to-front.

I remember trying out to use a VecDeque so that the drain above could happen at the back of the DOM order, which seemed not worth it for code size (VecDeque and Vec seem to not codeshare as much as I thought). I fully agree with your reasoning though: If the common failure case (mismatching lengths) leads to overhead downstream, then paying for two list reversals instead should be worth it. I just think that in the case of matching list lengths, no such reversing should need to take place.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

trying something in 8f3873d

Couldn't get the apply_keyed algorithm to work with render-order in a pinch.

Will experiment more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

good now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

wait. I see a small but constanst ~0.173 kB size increase across the example crates. will fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

maybe it's acceptible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I will try to see if I can find out what causes it, too, but it's very hard to inspect the resulting binaries in such detail 😓 could be a new panic path and error message, ~170 bytes is not a lot.

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
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