sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
77.94k stars 4.07k forks source link

Svelte 5: Maximum update depth exceeded. (effect_update_depth_exceeded) #11978

Open qupig opened 2 months ago

qupig commented 2 months ago

Describe the bug

I don't understand why the infinite loop happens.

<script>
    const items = $state([1,2,3]);

    async function init() {
        items.push(4);
    }
</script>

{#await init()}
    loading...
{:then}
    {#each items as item}
        <p>{item}</p>
    {/each}
{/await}
Svelte error: effect_update_depth_exceeded
Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops

Reproduction

Svelte-5-REPL

Logs

No response

System Info

Svelte compiler version 5.0.0-next.152

Severity

blocking an upgrade

zhihengGet commented 2 months ago

you await push, once it is pushed, then ui rerender which await again...

qupig commented 2 months ago

you await push, once it is pushed, then ui rerender which await again...

Why? I don't see any logical connection to this.

Why does updating $state re-render the await block?

When did await blocks become stateful?

CaptainCodeman commented 2 months ago

Yeah, there's nothing wrong with it IMO and the Svelte 4 version works fine. If you use the migrate button on the REPL it produces the code in the OP, along with the error.

<script>
    let items = [1,2,3]

    async function init() {
        items.push(4);
    }
</script>

{#await init()}
    loading...
{:then}
    {#each items as item}
        <p>{item}</p>
    {/each}
{/await}
brunnerh commented 2 months ago

#await blocks are triggered again if the value being awaited is changed. The same is true in Svelte 4, the difference in Svelte 5 is that reactivity crosses function boundaries by default.

This is essentially running items.push in an effect which produces a loop because the property access (items.push) and possibly internal access of length causes a dependency on items while at the same time items is being modified.

qupig commented 2 months ago

@brunnerh

#await blocks are triggered again if the value being awaited is changed.

To be honest, it is difficult to understand this sentence and it is not intuitive. But in order to understand as much as possible, I quickly wrote the following example:

<script>
    let obj = {a:1};

    async function init() {
        console.log("init");
        obj = {a:2};
        return obj;
    }
</script>

<button on:click={()=>(obj={a:3})}>click</button>
{#await init()}
    loading...
{:then}
    <p>{obj.a}</p>
{/await}

It turns out that the example works as you said in Svelte-5-REPL. But NOT Svelte-4-REPL (Please paste the code yourself).

The same is true in Svelte 4

So, could you please make an example to illustrate this, I can't imagine.

And there is no description of this in either the Svelte4 or Svelte5 documentation.

This approach is NOT intuitive anyway.

I never thought that the await function might be executed multiple times, and there is no return value at all. It's normal logic to manipulate and initialize state in an await function, and it's weird to prevent this. And can people really realize that the await block is actually a $effect? Anyway, I won't.

henrikvilhelmberglund commented 2 months ago

It does work if you do something like this: REPL

qupig commented 2 months ago

@henrikvilhelmberglund Thank you. Another ugly work around in Svelte 5.

MotionlessTrain commented 2 months ago

And can people really realize that the await block is actually a $effect? Anyway, I won't.

Everything in the markup is basically an $effect. Same holds true for e.g. if or each blocks

brunnerh commented 2 months ago

It turns out that the example works as you said in Svelte-5-REPL. But NOT Svelte-4-REPL (Please paste the code yourself).

As I already noted, the behavior is different because reactivity does not cross function boundaries in Svelte 4. If you change the code to actually account for that, you also run into an infinite loop in Svelte 4 but it is not caught (and it just crashed my browser).

To make the function fully reactive, it needs to be declared reactively:

- async function init() {
+ $: init = async () => {

A more simple and common example in Svelte 4 would be a promise variable that is just updated later (e.g. when paging).

let index = 0;
$: promise = loadPage(index);
{#await promise then page}
   ...
{/await}
<button on:click={() => index++}>Next</button>

REPL


You can also avoid the issue in Svelte 5 by using untrack if you read and write the same state.

untrack(() => items.push(4));

The example is also not very representative since everything after the first await will not be tracked anyway. This will not cause a loop:

async function init() {
    await Promise.resolve(); // e.g. fetch here
    items.push(4);
}

And often you will just assign a state and not mutate (causing a read first) in load functions like this.

qupig commented 2 months ago

@brunnerh Great presentation! Thanks!

since everything after the first await will not be tracked anyway

This explains why I ran into the issue when I temporarily removed the async api code to simulate locally.

qupig commented 2 months ago

@MotionlessTrain

Everything in the markup is basically an $effect. Same holds true for e.g. if or each blocks

For if or each this is easy to understand, but not await. But it looks like I need to understand it better.

Rich-Harris commented 2 months ago

Everything is working as it should — having side-effects like items.push(...) in your template is no different than doing something obviously wrong like this:

<script>
  let count = $state(0);
</script>

<p>{count++}</p>

For me the only question to me is 'how could we have made this easier to diagnose?'. The current message...

Maximum update depth exceeded. This can happen when a reactive block or effect repeatedly sets a new value. Svelte limits the number of nested updates to prevent infinite loops

...seems okay but clearly it could be improved. What if it gave more clarity as to where the loop was happening and explained that it happens when you read and write the same value? Like this, perhaps:

An {#await ...} block in Foo.svelte updated the value of items, which it also reads from, causing an infinite loop

It would take some finagling (we would need to add labels to sources in dev mode, for example) but it seems doable

qupig commented 2 months ago

@Rich-Harris For me, @brunnerh‘s examples and explanations help me understand more easily what's going on in specific Logic blocks.

Of course, smarter error messages are always better at helping people determine how the error occurred.

The issue itself no longer exists, unless you need it to be tracked to improve the documentation or other parts, please feel free to close it.

Thank you all again for creating everything and explaining the difficult parts so well.

Rich-Harris commented 2 months ago

We can keep it open to track the improved error message