Skip to content

fix: yield when 16ms has passed and no dom mutating tasks are pending#4033

Merged
Madoshakalaka merged 4 commits intomasterfrom
fix/periodic-yield
Mar 10, 2026
Merged

fix: yield when 16ms has passed and no dom mutating tasks are pending#4033
Madoshakalaka merged 4 commits intomasterfrom
fix/periodic-yield

Conversation

@Madoshakalaka
Copy link
Copy Markdown
Member

@Madoshakalaka Madoshakalaka commented Mar 2, 2026

Fixes #4032.

This makes the scheduler in the browser environment yield every ~16 ms

Concerns

- DOM may be partially updated when the browser paints during a yield. This is arguably better than the status quo, where the browser cannot paint at all until the entire batch completes, causing the page to appear frozen.

  • A single task that takes >16ms cannot be interrupted. The scheduler can only yield between tasks, not within task.run(). This is an inherent limitation -- individual component renders that are themselves expensive would need to be optimized at the component level.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 2, 2026

Visit the preview URL for this PR (updated for commit 46ce0d8):

https://yew-rs-api--pr4033-fix-periodic-yield-kmvhcr0x.web.app

(expires Sat, 14 Mar 2026 15:05:09 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 2, 2026

Benchmark - core

Yew Master

vnode           fastest       │ slowest       │ median        │ mean          │ samples │ iters
╰─ vnode_clone  2.153 ns      │ 3.404 ns      │ 2.155 ns      │ 2.272 ns      │ 100     │ 1000000000

Pull Request

vnode           fastest       │ slowest       │ median        │ mean          │ samples │ iters
╰─ vnode_clone  2.152 ns      │ 2.847 ns      │ 2.156 ns      │ 2.187 ns      │ 100     │ 1000000000

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 2, 2026

Size Comparison

Details
examples master (KB) pull request (KB) diff (KB) diff (%)
async_clock 100.272 101.055 +0.782 +0.780%
boids 168.092 168.874 +0.782 +0.465%
communication_child_to_parent 93.487 94.512 +1.024 +1.096%
communication_grandchild_with_grandparent 105.264 106.323 +1.060 +1.007%
communication_grandparent_to_grandchild 101.606 102.664 +1.058 +1.041%
communication_parent_to_child 90.897 91.921 +1.023 +1.126%
contexts 105.172 106.140 +0.968 +0.920%
counter 86.282 87.233 +0.951 +1.102%
counter_functional 88.275 89.270 +0.994 +1.126%
dyn_create_destroy_apps 90.322 91.149 +0.827 +0.916%
file_upload 99.377 100.340 +0.963 +0.969%
function_delayed_input 94.376 95.153 +0.777 +0.824%
function_memory_game 172.944 173.700 +0.756 +0.437%
function_router 406.546 407.355 +0.810 +0.199%
function_todomvc 164.161 165.154 +0.993 +0.605%
futures 235.191 236.116 +0.925 +0.393%
game_of_life 104.732 105.534 +0.802 +0.766%
immutable 255.899 259.940 +4.041 +1.579%
inner_html 80.803 81.776 +0.974 +1.205%
js_callback 109.399 110.340 +0.940 +0.860%
keyed_list 179.744 180.712 +0.968 +0.538%
mount_point 84.175 85.149 +0.975 +1.158%
nested_list 113.068 114.066 +0.998 +0.883%
node_refs 91.523 92.485 +0.962 +1.051%
password_strength 1728.855 1729.843 +0.987 +0.057%
portals 93.036 93.995 +0.959 +1.031%
router 377.116 377.908 +0.792 +0.210%
suspense 113.500 114.267 +0.767 +0.675%
timer 88.641 89.371 +0.730 +0.824%
timer_functional 98.878 99.630 +0.752 +0.760%
todomvc 142.114 143.076 +0.962 +0.677%
two_apps 86.176 87.146 +0.971 +1.126%
web_worker_fib 136.152 137.097 +0.944 +0.694%
web_worker_prime 187.383 188.343 +0.960 +0.512%
webgl 83.224 83.921 +0.697 +0.838%

⚠️ The following examples have changed their size significantly:

examples master (KB) pull request (KB) diff (KB) diff (%)
communication_child_to_parent 93.487 94.512 +1.024 +1.096%
communication_grandchild_with_grandparent 105.264 106.323 +1.060 +1.007%
communication_grandparent_to_grandchild 101.606 102.664 +1.058 +1.041%
communication_parent_to_child 90.897 91.921 +1.023 +1.126%
counter 86.282 87.233 +0.951 +1.102%
counter_functional 88.275 89.270 +0.994 +1.126%
immutable 255.899 259.940 +4.041 +1.579%
inner_html 80.803 81.776 +0.974 +1.205%
mount_point 84.175 85.149 +0.975 +1.158%
node_refs 91.523 92.485 +0.962 +1.051%
portals 93.036 93.995 +0.959 +1.031%
two_apps 86.176 87.146 +0.971 +1.126%

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 2, 2026

Benchmark - SSR

Yew Master

Details
Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 310.643 311.179 310.930 0.185
Hello World 10 472.164 523.233 480.994 15.919
Function Router 10 32428.029 34418.172 33409.261 503.454
Concurrent Task 10 1005.807 1007.367 1006.816 0.486
Many Providers 10 1068.302 1140.850 1086.024 21.663

Pull Request

Details
Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 310.667 318.885 311.729 2.522
Hello World 10 484.872 497.828 489.403 4.366
Function Router 10 31935.148 33369.082 32575.633 440.965
Concurrent Task 10 1005.155 1007.463 1006.594 0.764
Many Providers 10 1151.780 1216.164 1163.273 19.118

@Madoshakalaka
Copy link
Copy Markdown
Member Author

@Ddystopia do you wanna try the fix/periodic-yield branch in your project and see if it improves?

@WorldSEnder
Copy link
Copy Markdown
Member

  • DOM may be partially updated when the browser paints during a yield. This is arguably better than the status quo, where the browser cannot paint at all until the entire batch completes, causing the page to appear frozen.

Is it? I'm on board with doing this yield for user-defined and external tasks. But for anything inside of yew, yielding at an arbitrary point between updating a component and its children, or other point with the scheduler partially finished, means there could be an event handling interrupting it. The event handling part is currently unaware of this intermediate state and will run arbitrary used code that might interact with DOM at that point.

}

fn run_scheduler(mut queue: Vec<super::QueueEntry>) {
let mut deadline = js_sys::Date::now() + YIELD_DEADLINE_MS;
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.

an alternative is the Performance API which has μs resolution (as opposed to ms) and monotonicity, neither of which we really need.

@Madoshakalaka
Copy link
Copy Markdown
Member Author

The event handling part is currently unaware of this intermediate state and will run arbitrary used code that might interact with DOM at that point.

real, how about yields only when the render queues are empty?

@WorldSEnder
Copy link
Copy Markdown
Member

real, how about yields only when the render queues are empty?

I'm specifically talking about the closure that is used as the event handler which will get run immediately. Any change to a component including a re-render would go through the scheduler and run later. The event handler itself of course can not be delayed, since it might cancel the event which the browser must know about when the event handler returns.

@Ddystopia
Copy link
Copy Markdown

will run arbitrary used code that might interact with DOM at that point.

Even without yielding some actions trigger a forced reflow and dom changes, like setting a width to the node and then reading client rect.

@Ddystopia
Copy link
Copy Markdown

@Ddystopia do you wanna try the fix/periodic-yield branch in your project and see if it improves?

I can't run master due to some stale dependencies 😅

@WorldSEnder
Copy link
Copy Markdown
Member

Even without yielding some actions trigger a forced reflow and dom changes, like setting a width to the node and then reading client rect.

Yes, but I'm talking about a handler that expects the DOM to be in a state consistent with the last rendered vdom/component state, which might not be the case if it we yield while updating the node's children but after installing the handler itself.

@Madoshakalaka
Copy link
Copy Markdown
Member Author

@WorldSEnder I'm thinking of only yielding when no DOM-mutating work is pending.

Tracing through every Runnable implementation:

destroy, create, render_first, render, render_priority

These touch the DOM (or are always paired with something that does). Not safe to yield while any of these have pending items.

rendered, rendered_first, update, props_update, main

Lifecycle callbacks or state updates that don't directly mutate the DOM. Safe to have pending at yield time.

This means a single render cascade that exceeds 50ms still blocks (inherent limitation, can't split a parent/child render tree), but between independent update cycles we yield as intended.

Thoughts?

@WorldSEnder
Copy link
Copy Markdown
Member

Sounds good to me. Any specific reason to use 50ms here, btw? I'd expected something close to either 30 or 60 fps so to speak (~33m and ~16ms respectively) but that's just a first naive thought.

@Madoshakalaka
Copy link
Copy Markdown
Member Author

Madoshakalaka commented Mar 3, 2026

It's from the article https://web.dev/articles/optimize-long-tasks?utm_source=devtools

The main thread can only process one task at a time. Any task that takes longer than 50 milliseconds is a long task. For tasks that exceed 50 milliseconds, the task's total time minus 50 milliseconds is known as the task's blocking period.

But since we are not yielding consistently, whatever we set will be an lower bound.

33ms or 16ms makes more sense I think.

@Madoshakalaka

This comment was marked as outdated.

@WorldSEnder
Copy link
Copy Markdown
Member

The issue is that can_yield doesn't check queues whose tasks cascade into DOM mutations:

Isn't the issue that the test is wrong and expects everything to be settled after sleep(0)? It should wait on the scheduler finishing (and there might be other tests with the same issue).

@Madoshakalaka
Copy link
Copy Markdown
Member Author

Isn't the issue that the test is wrong and expects everything to be settled after sleep(0)?

I was thinking of that actually.

will #2679 help?

@WorldSEnder
Copy link
Copy Markdown
Member

will #2679 help?

That was an attempt at that, but abondoned for being too complex and also trying to integrate with playwright and actual e2e testing frameworks which was much more complicated than I first thought.

Madoshakalaka and others added 3 commits March 7, 2026 23:39
Replaces the async/await yield mechanism (JsFuture + Promise) with a
synchronous callback approach (Closure::once_into_js + setTimeout).
This avoids pulling in the JsFuture state machine and web_sys::window()
binding, cutting the wasm size overhead roughly in half (~0.9KB vs ~1.8KB).
Yield to the browser only when no DOM-mutating work (destroy, create,
render_first, render, render_priority) is pending, so event handlers
that fire during the yield never see a partially-rendered tree.

Also lower yield deadline from 50ms to 16ms (~60fps) and gate
start_now() to non-wasm/test targets where it is actually used.
@Madoshakalaka
Copy link
Copy Markdown
Member Author

rebased onto master and it seems the flush function worked wonders

@Madoshakalaka Madoshakalaka marked this pull request as ready for review March 7, 2026 15:26
@Madoshakalaka Madoshakalaka changed the title fix: yield every 50ms fix: yield when 16ms has passed and no dom mutating tasks are pending Mar 7, 2026
@Madoshakalaka Madoshakalaka merged commit 6227e5e into master Mar 10, 2026
38 checks passed
@Madoshakalaka Madoshakalaka deleted the fix/periodic-yield branch March 12, 2026 06:00
@WorldSEnder WorldSEnder mentioned this pull request Mar 17, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Yew blocks the main thread often while processing tasks

3 participants