Repository: plotly/dash

Issue:
[Feature Request] Enable running functionality in non-background callbacks
The running keyword in callbacks currently only works when `background=True` is set for callbacks. However, sometimes you have a normal callback, that takes a while to execute, that you don't want to turn in a background callback, but which you do want to have running functionality for. 

Currently, I solve this by manually creating a callback loop, i.e. by having an entry-exit callback that disables/enables the button which then triggers a second callback that does the actual calculation. But that is a bit cumbersome to set up every time, since you have to be careful to make sure that the loop properly closes. For example, if you run the same calculation twice consecutively, the exit callback might not be triggered the second time because the calculation return data didn't change compared to the first run and therefore Dash won't update the component that triggers the loop exit condition. (The solution is to add extra data that will always update, e.g. a calculation counter)

Having this functionality enabled via the running keyword that already works for background callbacks would be amazing.




Discussion:

Resolving patch:
diff --git a/dash/_callback.py b/dash/_callback.py
--- a/dash/_callback.py
+++ b/dash/_callback.py
@@ -166,10 +166,6 @@ def callback(
                     "Progress and progress default needs to be of same length"
                 )
 
-        if running:
-            long_spec["running"] = coerce_to_list(running)
-            validate_long_inputs(x[0] for x in long_spec["running"])
-
         if cancel:
             cancel_inputs = coerce_to_list(cancel)
             validate_long_inputs(cancel_inputs)
@@ -188,6 +184,7 @@ def callback(
         **_kwargs,
         long=long_spec,
         manager=manager,
+        running=running,
     )
 
 
@@ -227,6 +224,7 @@ def insert_callback(
     prevent_initial_call,
     long=None,
     manager=None,
+    running=None,
     dynamic_creator=False,
 ):
     if prevent_initial_call is None:
@@ -251,6 +249,8 @@ def insert_callback(
         },
         "dynamic_creator": dynamic_creator,
     }
+    if running:
+        callback_spec["running"] = running
 
     callback_map[callback_id] = {
         "inputs": callback_spec["inputs"],
@@ -289,6 +289,14 @@ def register_callback(  # pylint: disable=R0914
 
     long = _kwargs.get("long")
     manager = _kwargs.get("manager")
+    running = _kwargs.get("running")
+    if running is not None:
+        if not isinstance(running[0], (list, tuple)):
+            running = [running]
+        running = {
+            "running": {str(r[0]): r[1] for r in running},
+            "runningOff": {str(r[0]): r[2] for r in running},
+        }
     allow_dynamic_callbacks = _kwargs.get("_allow_dynamic_callbacks")
 
     output_indices = make_grouping_by_index(output, list(range(grouping_len(output))))
@@ -305,6 +313,7 @@ def register_callback(  # pylint: disable=R0914
         long=long,
         manager=manager,
         dynamic_creator=allow_dynamic_callbacks,
+        running=running,
     )
 
     # pylint: disable=too-many-locals
@@ -389,11 +398,6 @@ def add_context(*args, **kwargs):
                         "job": job,
                     }
 
-                    running = long.get("running")
-
-                    if running:
-                        data["running"] = {str(r[0]): r[1] for r in running}
-                        data["runningOff"] = {str(r[0]): r[2] for r in running}
                     cancel = long.get("cancel")
                     if cancel:
                         data["cancel"] = cancel
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -1113,7 +1113,10 @@ def interpolate_index(self, **kwargs):
         )
 
     def dependencies(self):
-        return flask.jsonify(self._callback_list)
+        return flask.Response(
+            to_json(self._callback_list),
+            content_type="application/json",
+        )
 
     def clientside_callback(self, clientside_function, *args, **kwargs):
         """Create a callback that updates the output by calling a clientside


Test patch:
diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py
--- a/tests/integration/callbacks/test_basic_callback.py
+++ b/tests/integration/callbacks/test_basic_callback.py
@@ -791,3 +791,35 @@ def on_click(_):
     dash_duo.start_server(app)
 
     assert dash_duo.get_logs() == []
+
+
+def test_cbsc019_callback_running(dash_duo):
+    lock = Lock()
+    app = Dash(__name__)
+
+    app.layout = html.Div(
+        [
+            html.Div("off", id="running"),
+            html.Button("start", id="start"),
+            html.Div(id="output"),
+        ]
+    )
+
+    @app.callback(
+        Output("output", "children"),
+        Input("start", "n_clicks"),
+        running=[[Output("running", "children"), html.B("on", id="content"), "off"]],
+        prevent_initial_call=True,
+    )
+    def on_click(_):
+        with lock:
+            pass
+        return "done"
+
+    dash_duo.start_server(app)
+    with lock:
+        dash_duo.find_element("#start").click()
+        dash_duo.wait_for_text_to_equal("#content", "on")
+
+    dash_duo.wait_for_text_to_equal("#output", "done")
+    dash_duo.wait_for_text_to_equal("#running", "off")
diff --git a/tests/integration/long_callback/test_basic_long_callback016.py b/tests/integration/long_callback/test_basic_long_callback016.py
--- a/tests/integration/long_callback/test_basic_long_callback016.py
+++ b/tests/integration/long_callback/test_basic_long_callback016.py
@@ -7,7 +7,7 @@
 
 
 @pytest.mark.skipif(
-    sys.version_info < (3, 7), reason="Python 3.6 long callbacks tests hangs up"
+    sys.version_info < (3, 9), reason="Python 3.8 long callbacks tests hangs up"
 )
 def test_lcbc016_multi_page_cancel(dash_duo, manager):
     with setup_long_callback_app(manager, "app_page_cancel") as app: