DioxusLabs / dioxus

App framework for web, desktop, mobile, and more.
https://dioxuslabs.com
Apache License 2.0
20.43k stars 786 forks source link

Warnings for signal writes that may cause infinite loops #2277

Closed ealmloff closed 5 months ago

ealmloff commented 5 months ago

This adds two new warnings for signals:

Writing to a signal in the body of a component

Writing to a signal in the body of a component can cause an infinite loop.

For example this code:

fn app() -> Element {
    let mut count = use_signal(|| 0);
    count += 1;

    rsx! {
        h1 { "High-Five counter: {count}" }
        button { onclick: move |_| count += 1, "Up high!" }
        button { onclick: move |_| count -= 1, "Down low!" }
    }
}

Now produces these warnings:

2024-04-08T17:28:04.697417Z  WARN dioxus_signals::signal: Write on signal at examples/readme.rs:17:5 happened while a component was running. Writing to signals during a render can cause infinite rerenders when you read the same signal in the component. Consider writing to the signal in an effect, future, or event handler if possible.
2024-04-08T17:28:04.697474Z  WARN dioxus_signals::signal: Write on signal at examples/readme.rs:17:5 finished in ReactiveContext(for scope: ScopeId(0, "app")) which is also subscribed to the signal. This will likely cause an infinite loop. When the write finishes, ReactiveContext(for scope: ScopeId(0, "app")) will rerun which may cause the write to be rerun again. Consider separating the subscriptions by splitting the state into multiple signals or only reading the part of the signal that you need in a memo.
This issue is caused by reading and writing to the same signal in a reactive scope. Components, effects, memos, and resources each have their own a reactive scopes. Reactive scopes rerun when any signal you read inside of them are changed. If you read and write to the same signal in the same scope, the write will cause the scope to rerun and trigger the write again. This can cause an infinite loop.
You can fix the issue by either:
1) Splitting up your state and Writing, reading to different signals:
For example, you could change this broken code:
#[derive(Clone, Copy)]
struct Counts {
    count1: i32,
    count2: i32,
}
fn app() -> Element {
    let mut counts = use_signal(|| Counts { count1: 0, count2: 0 });

    use_effect(move || {
        // This effect both reads and writes to counts
        counts.write().count1 = counts().count2;
    })
}
Into this working code:
fn app() -> Element {
    let mut count1 = use_signal(|| 0);
    let mut count2 = use_signal(|| 0);
    use_effect(move || {
        count1.write(count2());
    });
}
2) Reading and Writing to the same signal in different scopes:
For example, you could change this broken code:
fn app() -> Element {
    let mut count = use_signal(|| 0);
    use_effect(move || {
        // This effect both reads and writes to count
        println!("{}", count());
        count.write(count());
    });
}
To this working code:
fn app() -> Element {
    let mut count = use_signal(|| 0);
    use_effect(move || {
        count.write(count());
    });
    use_effect(move || {
        println!("{}", count());
    });
}

Writing to a signal in a reactive context the signal is subscribed to

If you read and write to the same signal in a reactive scope, it can cause an infinite loop.

For example this code:

fn app() -> Element {
    let mut count = use_signal(|| 0);
    use_effect(move || {
        println!("Counter changed to {count}");
        count += 1;
    });

    rsx! {
        h1 { "High-Five counter: {count}" }
        button { onclick: move |_| count += 1, "Up high!" }
        button { onclick: move |_| count -= 1, "Down low!" }
    }
}

Now produces this warning:

2024-04-08T17:28:04.697474Z  WARN dioxus_signals::signal: Write on signal at examples/readme.rs:19:9 finished in ReactiveContext created at examples/readme.rs:17:5 which is also subscribed to the signal. This will likely cause an infinite loop. When the write finishes, ReactiveContext created at examples/readme.rs:17:5 will rerun which may cause the write to be rerun again. Consider separating the subscriptions by splitting the state into multiple signals or only reading the part of the signal that you need in a memo.
This issue is caused by reading and writing to the same signal in a reactive scope. Components, effects, memos, and resources each have their own a reactive scopes. Reactive scopes rerun when any signal you read inside of them are changed. If you read and write to the same signal in the same scope, the write will cause the scope to rerun and trigger the write again. This can cause an infinite loop.
You can fix the issue by either:
1) Splitting up your state and Writing, reading to different signals:
For example, you could change this broken code:
#[derive(Clone, Copy)]
struct Counts {
    count1: i32,
    count2: i32,
}
fn app() -> Element {
    let mut counts = use_signal(|| Counts { count1: 0, count2: 0 });

    use_effect(move || {
        // This effect both reads and writes to counts
        counts.write().count1 = counts().count2;
    })
}
Into this working code:
fn app() -> Element {
    let mut count1 = use_signal(|| 0);
    let mut count2 = use_signal(|| 0);
    use_effect(move || {
        count1.write(count2());
    });
}
2) Reading and Writing to the same signal in different scopes:
For example, you could change this broken code:
fn app() -> Element {
    let mut count = use_signal(|| 0);
    use_effect(move || {
        // This effect both reads and writes to count
        println!("{}", count());
        count.write(count());
    });
}
To this working code:
fn app() -> Element {
    let mut count = use_signal(|| 0);
    use_effect(move || {
        count.write(count());
    });
    use_effect(move || {
        println!("{}", count());
    });
}

Closes https://github.com/DioxusLabs/dioxus/issues/1507