van icon indicating copy to clipboard operation
van copied to clipboard

Why use setTimeout trigger effect rather than queueMicrotask

Open cubxx opened this issue 4 months ago • 15 comments

I found the update is always slow by 1 frame when I change state in the rAF callback:

//minimal case
const a = van.state(0);
van.dervie(() => (el.textContent = a.val));
requestAnimationFrame(async (time) => {
  a.val = time;
  await 0; // wait next microtask
  console.log(time === el.textContent); // expect true but got false
});

I know it is because the setTimeout callback will be called after the render pipeline, but updating the DOM in time in the rAF callback is usual practice. I found that the other reactive systems usually update on the next microtask, which ensures that the update will occur before the render pipeline.

https://jsfiddle.net/akqjhcy6/ https://playground.solidjs.com/anonymous/01307c7d-763d-4d87-a00b-432f12b85936

So, is there any reason why setTimeout must be used?

cubxx avatar Sep 21 '25 14:09 cubxx

TBH I'm not that familiar with requestAnimationFrame. I chose setTimeout since it's easier to understand.

My mental model of the state change is: when you change the state while rendering the current frame, you're planning what to show next. Otherwise, when you're rendering the current frame, and you can change what to render in the current frame, things might become messy (not sure if it can lead to infinite loop).

Also, state derivation might be used for non-rendering purposes, coupling with rendering-related function might not be a good idea.

Tao-VanJS avatar Sep 21 '25 17:09 Tao-VanJS

This pic below clearly shows what happened when I executed the code below. The purple and green blocks are render tasks. Before them, you can see the rAF callback was called, which adds a microtask and 2 timer tasks. You can see that the side effect is always called after the render pipeline.

    const state = van.state(0);
    const double = van.derive(() => state.val * 2);
    requestAnimationFrame(() => {
      state.val = 1; // add timer task
      Promise.resolve().then(() => expect(double.val).to.equal(0)); // add microtask
      setTimeout(() => expect(double.val).to.equal(2)); // add timer task
    });
Image

In the pic below, we can see that the effect will trigger the recalculation style in the next frame, not the current frame, which is why we can not get the correct style in the code below, and that is why the mainstream frameworks push the effect into microtasks, not timer tasks.

    const state = van.state(0);
    const el = div({ style: () => `height: ${state.val}px` });
    van.derive(
      // not recalculate style yet, should wait to next frame
      () => state.val === 100 && expect(el.style.height).to.equal('0px'),
    );
    state.val = 100;
    van.add(document.body, el);
Image

This diff between state and dom usually results in a high mental burden. I think turning timer tasks into microtasks is absolutely safe if you don't need to do something after rendering.

cubxx avatar Sep 22 '25 07:09 cubxx

Based on your input, I feel that it probably makes more sense to change VanJS implementation by using Promise.resolve().then(updateDoms) instead of setTimeout(updateDoms). As you illustrated, the callback via Promise.resolve().then(...) will be scheduled faster (as a microtask) compared to setTimeout(...) (as a timer task). Moreover, Promise.resolve().then(...) has the FIFO guarantee. Specifically, if you update a state and then later calls Promise.resolve().then(...) or await 0, it's guaranteed that your callback can see the state changes there.

Let me know if the change makes sense to you. If so, I can release a new version of VanJS to change that.

Tao-VanJS avatar Sep 22 '25 19:09 Tao-VanJS

@Tao-VanJS, why not just use queueMicrotask instead of Promise.resolve().then(...), it should be use less overhead

creating and destroying promises takes additional overhead both in terms of time and memory that a function which properly enqueues microtasks avoids

https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide#enqueueing_microtasks

sirenkovladd avatar Sep 22 '25 22:09 sirenkovladd

Promise.resolve().then has better compatibility, which is used in Vue. queueMicrotask has less code size, and it is used in Solid and Svelte.

This is a trade-off; I prefer the latter.

cubxx avatar Sep 23 '25 04:09 cubxx

FYI, I just released VanJS 1.6.0 which switched to use queueMicrotask. Thank you so much for the discussion!

Tao-VanJS avatar Sep 24 '25 00:09 Tao-VanJS

@all-contributors please add @cubxx for ideas

Tao-VanJS avatar Sep 24 '25 00:09 Tao-VanJS

@Tao-VanJS

I've put up a pull request to add @cubxx! :tada:

allcontributors[bot] avatar Sep 24 '25 00:09 allcontributors[bot]

@all-contributors please add @sirenkovladd for ideas

Tao-VanJS avatar Sep 24 '25 00:09 Tao-VanJS

@Tao-VanJS

I've put up a pull request to add @sirenkovladd! :tada:

allcontributors[bot] avatar Sep 24 '25 00:09 allcontributors[bot]

@Tao-VanJS Thanks for your work. I'm writing this comment solely to warn something.

In the case below, el.style.height remains 1px when the effect was called for the 2nd time. This is because update will be called after all effects have completed.

    const s = van.state(1);
    const el = div({
      style: () => (
        performance.mark('style update: ' + s.val),
        `height: ${s.val}px`
      ),
    });

    const effect = () =>
      performance.mark(`effect: ${s.val} ${el.style.height}`); // 1px
    van.derive(effect);
    queueMicrotask(() =>
      performance.mark(`microtask: ${s.val} ${el.style.height}`), // 2px
    );

    van.add(document.body, el);
    s.val = 2;
Image

I'm not sure if this aligns with your expectations. Anyway, we can still use queueMicrotask to access the correct style.

cubxx avatar Sep 24 '25 07:09 cubxx

My expectation in the new version is: if you update a state (e.g.: s.val = 2), and subsequently call queueMicrotask(callback), the callback will see the state changes. But if queueMicrotask(callback) is called before the state change, callback won't see the state change.

Tao-VanJS avatar Sep 24 '25 15:09 Tao-VanJS

I think there is a regression when using vanX reactive? It seems not even simple () => state.key are reactive when using the latest version. Maybe in vanX package, the vanjs-core could be defined as a peer dependnecy?

raduconst06 avatar Sep 28 '25 18:09 raduconst06

I think there is a regression when using vanX reactive?

Hmm..., I'm not seeing any regression in VanX apps after the release. And I did make sure all VanX test cases pass before doing the release.

Tao-VanJS avatar Sep 28 '25 19:09 Tao-VanJS

While trying to set up a repo to reproduce the issue, I have identified the root cause.

initially I had:

"vanjs-core": "1.5.5",
"vanjs-ext": "^0.6.3",

After upgrade:

"vanjs-core": "1.6.0",
"vanjs-ext": "^0.6.3",

Because vanjs-ext has on the latest version available in npm ("version": "0.6.3"):

"dependencies": {
"vanjs-core": "^1.5.5"
},

This created two instances of vanjs-core, one with v1.5.5 used by vanjs-ext and the other by my app. The solution was to remove node_modules and lock file then reinstall and the package was deduped on the same version. And now it's works correctly.

raduconst06 avatar Sep 29 '25 06:09 raduconst06