Repository: plotly/dash

Issue:
[BUG] set_props called multiple times only keep the last props.
For regular callbacks, when multiple call of `set_props` to the same component id, only the last call is saved.

Example:
```
from dash import Dash, Input, html, set_props

app = Dash()

app.layout = [
    html.Button("start", id="start"),
    html.Div("initial", id="output"),
]

@app.callback(
    Input("start", "n_clicks"),
)
def on_click(_):
    set_props("output", {"children": "changed"})
    set_props("output", {"style": {"background": "red"}})

if __name__ == "__main__":
    app.run(debug=True)

```

Clicking on the start button only set the background red, the text stays at "initial". The props should be merged and both updated.




Discussion:

Resolving patch:
diff --git a/dash/_callback_context.py b/dash/_callback_context.py
--- a/dash/_callback_context.py
+++ b/dash/_callback_context.py
@@ -252,7 +252,11 @@ def timing_information(self):
     def set_props(self, component_id: typing.Union[str, dict], props: dict):
         ctx_value = _get_context_value()
         _id = stringify_id(component_id)
-        ctx_value.updated_props[_id] = props
+        existing = ctx_value.updated_props.get(_id)
+        if existing is not None:
+            ctx_value.updated_props[_id] = {**existing, **props}
+        else:
+            ctx_value.updated_props[_id] = props
 
 
 callback_context = CallbackContext()
diff --git a/dash/long_callback/_proxy_set_props.py b/dash/long_callback/_proxy_set_props.py
--- a/dash/long_callback/_proxy_set_props.py
+++ b/dash/long_callback/_proxy_set_props.py
@@ -7,6 +7,12 @@ class ProxySetProps(dict):
     def __init__(self, on_change):
         super().__init__()
         self.on_change = on_change
+        self._data = {}
 
     def __setitem__(self, key, value):
         self.on_change(key, value)
+        self._data.setdefault(key, {})
+        self._data[key] = {**self._data[key], **value}
+
+    def get(self, key):
+        return self._data.get(key)


Test patch:
diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py
--- a/tests/integration/callbacks/test_arbitrary_callbacks.py
+++ b/tests/integration/callbacks/test_arbitrary_callbacks.py
@@ -165,3 +165,26 @@ def on_click(clicked):
         ".dash-fe-error__title",
         "Callback error with no output from input start.n_clicks",
     )
+
+
+def test_arb006_multi_set_props(dash_duo):
+    app = Dash()
+
+    app.layout = [
+        html.Button("start", id="start"),
+        html.Div("initial", id="output"),
+    ]
+
+    @app.callback(
+        Input("start", "n_clicks"),
+    )
+    def on_click(_):
+        set_props("output", {"children": "changed"})
+        set_props("output", {"style": {"background": "rgb(255,0,0)"}})
+
+    dash_duo.start_server(app)
+    dash_duo.wait_for_element("#start").click()
+    dash_duo.wait_for_text_to_equal("#output", "changed")
+    dash_duo.wait_for_style_to_equal(
+        "#output", "background-color", "rgba(255, 0, 0, 1)"
+    )
diff --git a/tests/integration/long_callback/app_arbitrary.py b/tests/integration/long_callback/app_arbitrary.py
--- a/tests/integration/long_callback/app_arbitrary.py
+++ b/tests/integration/long_callback/app_arbitrary.py
@@ -25,9 +25,11 @@
     Input("start", "n_clicks"),
     prevent_initial_call=True,
     background=True,
+    interval=500,
 )
 def on_click(_):
     set_props("secondary", {"children": "first"})
+    set_props("secondary", {"style": {"background": "red"}})
     time.sleep(2)
     set_props("secondary", {"children": "second"})
     return "completed"
diff --git a/tests/integration/long_callback/test_basic_long_callback017.py b/tests/integration/long_callback/test_basic_long_callback017.py
--- a/tests/integration/long_callback/test_basic_long_callback017.py
+++ b/tests/integration/long_callback/test_basic_long_callback017.py
@@ -8,6 +8,9 @@ def test_lcbc017_long_callback_set_props(dash_duo, manager):
         dash_duo.find_element("#start").click()
 
         dash_duo.wait_for_text_to_equal("#secondary", "first")
+        dash_duo.wait_for_style_to_equal(
+            "#secondary", "background-color", "rgba(255, 0, 0, 1)"
+        )
         dash_duo.wait_for_text_to_equal("#output", "initial")
         dash_duo.wait_for_text_to_equal("#secondary", "second")
         dash_duo.wait_for_text_to_equal("#output", "completed")