Repository: plotly/dash

Issue:
need a way to control which output i don’t want to update
the multiple outputs is great but i face some issue.
if i have a lots of outputs, it need a way to control which output i don’t want to update .
the code maybe like as follows:
```

@app.callback([
    Output('graph', 'figure'),
    Output('data-table', 'data'),
    Output('data-table', 'columns'),
    Output('container', 'style')
], [Input('data-dropdown', 'value')])
def multi_output(value):
    if condition_1:
        ...
        return [preventupdate, b, c, d]
    elif condition_2:
       ...
       return [a, preventupdate, c, d]
    else:
       ...
       return [a, b, c, preventupdate]
```


Discussion:

Resolving patch:
diff --git a/dash/__init__.py b/dash/__init__.py
--- a/dash/__init__.py
+++ b/dash/__init__.py
@@ -1,4 +1,4 @@
-from .dash import Dash  # noqa: F401
+from .dash import Dash, no_update  # noqa: F401
 from . import dependencies  # noqa: F401
 from . import development  # noqa: F401
 from . import exceptions  # noqa: F401
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -74,6 +74,15 @@
 _re_renderer_scripts_id = re.compile(r'id="_dash-renderer')
 
 
+class _NoUpdate(object):
+    # pylint: disable=too-few-public-methods
+    pass
+
+
+# Singleton signal to not update an output, alternative to PreventUpdate
+no_update = _NoUpdate()
+
+
 # pylint: disable=too-many-instance-attributes
 # pylint: disable=too-many-arguments, too-many-locals
 class Dash(object):
@@ -1053,15 +1062,25 @@ def add_context(*args, **kwargs):
                         )
 
                     component_ids = collections.defaultdict(dict)
+                    has_update = False
                     for i, o in enumerate(output):
-                        component_ids[o.component_id][o.component_property] =\
-                            output_value[i]
+                        val = output_value[i]
+                        if val is not no_update:
+                            has_update = True
+                            o_id, o_prop = o.component_id, o.component_property
+                            component_ids[o_id][o_prop] = val
+
+                    if not has_update:
+                        raise exceptions.PreventUpdate
 
                     response = {
                         'response': component_ids,
                         'multi': True
                     }
                 else:
+                    if output_value is no_update:
+                        raise exceptions.PreventUpdate
+
                     response = {
                         'response': {
                             'props': {


Test patch:
diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py
--- a/tests/IntegrationTests.py
+++ b/tests/IntegrationTests.py
@@ -21,15 +21,23 @@ def percy_snapshot(cls, name=''):
             name=snapshot_name
         )
 
-    def wait_for_element_by_css_selector(self, selector):
-        return WebDriverWait(self.driver, TIMEOUT).until(
-            EC.presence_of_element_located((By.CSS_SELECTOR, selector))
+    def wait_for_element_by_css_selector(self, selector, timeout=TIMEOUT):
+        return WebDriverWait(self.driver, timeout).until(
+            EC.presence_of_element_located((By.CSS_SELECTOR, selector)),
+            'Could not find element with selector "{}"'.format(selector)
         )
 
-    def wait_for_text_to_equal(self, selector, assertion_text):
-        return WebDriverWait(self.driver, TIMEOUT).until(
-            EC.text_to_be_present_in_element((By.CSS_SELECTOR, selector),
-                                             assertion_text)
+    def wait_for_text_to_equal(self, selector, assertion_text, timeout=TIMEOUT):
+        el = self.wait_for_element_by_css_selector(selector)
+        WebDriverWait(self.driver, timeout).until(
+            lambda *args: (
+                (str(el.text) == assertion_text) or
+                (str(el.get_attribute('value')) == assertion_text)
+            ),
+            "Element '{}' text was supposed to equal '{}' but it didn't".format(
+                selector,
+                assertion_text
+            )
         )
 
     @classmethod
diff --git a/tests/test_integration.py b/tests/test_integration.py
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -13,7 +13,7 @@
 import dash_html_components as html
 import dash_core_components as dcc
 
-import dash
+from dash import Dash, callback_context, no_update
 
 from dash.dependencies import Input, Output, State
 from dash.exceptions import (
@@ -34,7 +34,7 @@ def wait_for_element_by_id(id):
         self.wait_for_element_by_id = wait_for_element_by_id
 
     def test_simple_callback(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             dcc.Input(
                 id='input',
@@ -54,7 +54,7 @@ def test_simple_callback(self):
 
         @app.callback(Output('output-1', 'children'), [Input('input', 'value')])
         def update_output(value):
-            call_count.value = call_count.value + 1
+            call_count.value += 1
             return value
 
         self.startServer(app)
@@ -90,7 +90,7 @@ def update_output(value):
         assert_clean_console(self)
 
     def test_wildcard_callback(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             dcc.Input(
                 id='input',
@@ -111,7 +111,7 @@ def test_wildcard_callback(self):
 
         @app.callback(Output('output-1', 'data-cb'), [Input('input', 'value')])
         def update_data(value):
-            input_call_count.value = input_call_count.value + 1
+            input_call_count.value += 1
             return value
 
         @app.callback(Output('output-1', 'children'),
@@ -150,12 +150,15 @@ def update_text(data):
         assert_clean_console(self)
 
     def test_aborted_callback(self):
-        """Raising PreventUpdate prevents update and triggering dependencies"""
+        """
+        Raising PreventUpdate OR returning no_update
+        prevents update and triggering dependencies
+        """
 
         initial_input = 'initial input'
         initial_output = 'initial output'
 
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             dcc.Input(id='input', value=initial_input),
             html.Div(initial_output, id='output1'),
@@ -167,25 +170,27 @@ def test_aborted_callback(self):
 
         @app.callback(Output('output1', 'children'), [Input('input', 'value')])
         def callback1(value):
-            callback1_count.value = callback1_count.value + 1
+            callback1_count.value += 1
+            if callback1_count.value > 2:
+                return no_update
             raise PreventUpdate("testing callback does not update")
             return value
 
         @app.callback(Output('output2', 'children'), [Input('output1', 'children')])
         def callback2(value):
-            callback2_count.value = callback2_count.value + 1
+            callback2_count.value += 1
             return value
 
         self.startServer(app)
 
         input_ = self.wait_for_element_by_id('input')
-        input_.clear()
-        input_.send_keys('x')
+        input_.send_keys('xyz')
+        self.wait_for_text_to_equal('#input', 'initial inputxyz')
         output1 = self.wait_for_element_by_id('output1')
         output2 = self.wait_for_element_by_id('output2')
 
-        # callback1 runs twice (initial page load and through send_keys)
-        self.assertEqual(callback1_count.value, 2)
+        # callback1 runs 4x (initial page load and 3x through send_keys)
+        self.assertEqual(callback1_count.value, 4)
 
         # callback2 is never triggered, even on initial load
         self.assertEqual(callback2_count.value, 0)
@@ -199,7 +204,7 @@ def callback2(value):
         self.percy_snapshot(name='aborted')
 
     def test_wildcard_data_attributes(self):
-        app = dash.Dash()
+        app = Dash()
         test_time = datetime.datetime(2012, 1, 10, 2, 3)
         test_date = datetime.date(test_time.year, test_time.month,
                                   test_time.day)
@@ -257,7 +262,7 @@ def test_wildcard_data_attributes(self):
         assert_clean_console(self)
 
     def test_no_props_component(self):
-        app = dash.Dash()
+        app = Dash()
         app.layout = html.Div([
             dash_dangerously_set_inner_html.DangerouslySetInnerHTML('''
                 <h1>No Props Component</h1>
@@ -268,7 +273,7 @@ def test_no_props_component(self):
         self.percy_snapshot(name='no-props-component')
 
     def test_flow_component(self):
-        app = dash.Dash()
+        app = Dash()
 
         app.layout = html.Div([
             dash_flow_example.ExampleReactComponent(
@@ -309,7 +314,7 @@ def test_meta_tags(self):
             {'name': 'custom', 'content': 'customized'},
         ]
 
-        app = dash.Dash(meta_tags=metas)
+        app = Dash(meta_tags=metas)
 
         app.layout = html.Div(id='content')
 
@@ -329,7 +334,7 @@ def test_meta_tags(self):
             self.assertEqual(content, meta_info['content'])
 
     def test_index_customization(self):
-        app = dash.Dash()
+        app = Dash()
 
         app.index_string = '''
         <!DOCTYPE html>
@@ -384,8 +389,7 @@ def test_index_customization(self):
         self.percy_snapshot('custom-index')
 
     def test_assets(self):
-        app = dash.Dash(__name__,
-                        assets_ignore='.*ignored.*')
+        app = Dash(__name__, assets_ignore='.*ignored.*')
         app.index_string = '''
         <!DOCTYPE html>
         <html>
@@ -437,7 +441,7 @@ def test_assets(self):
         self.percy_snapshot('test assets includes')
 
     def test_invalid_index_string(self):
-        app = dash.Dash()
+        app = Dash()
 
         def will_raise():
             app.index_string = '''
@@ -496,9 +500,9 @@ def test_external_files_init(self):
             }
         ]
 
-        app = dash.Dash(__name__,
-                        external_scripts=js_files,
-                        external_stylesheets=css_files)
+        app = Dash(__name__,
+                   external_scripts=js_files,
+                   external_stylesheets=css_files)
 
         app.index_string = '''
         <!DOCTYPE html>
@@ -547,7 +551,7 @@ def test_external_files_init(self):
 
     def test_func_layout_accepted(self):
 
-        app = dash.Dash()
+        app = Dash()
 
         def create_layout():
             return html.Div('Hello World')
@@ -557,7 +561,7 @@ def create_layout():
         time.sleep(0.5)
 
     def test_multi_output(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.scripts.config.serve_locally = True
 
         app.layout = html.Div([
@@ -657,8 +661,100 @@ def overlapping_multi_output(n_clicks):
 
         self.assertGreater(int(output2.text), t)
 
+    def test_multi_output_no_update(self):
+        app = Dash(__name__)
+        app.scripts.config.serve_locally = True
+
+        app.layout = html.Div([
+            html.Button('B', 'btn'),
+            html.P('initial1', 'n1'),
+            html.P('initial2', 'n2'),
+            html.P('initial3', 'n3')
+        ])
+
+        @app.callback([Output('n1', 'children'),
+                       Output('n2', 'children'),
+                       Output('n3', 'children')],
+                      [Input('btn', 'n_clicks')])
+        def show_clicks(n):
+            # partial or complete cancelation of updates via no_update
+            return [
+                no_update if n and n > 4 else n,
+                no_update if n and n > 2 else n,
+                no_update
+            ]
+
+        self.startServer(app)
+
+        btn = self.wait_for_element_by_id('btn')
+        for _ in range(10):
+            btn.click()
+
+        self.wait_for_text_to_equal('#n1', '4')
+        self.wait_for_text_to_equal('#n2', '2')
+        self.wait_for_text_to_equal('#n3', 'initial3')
+
+    def test_no_update_chains(self):
+        app = Dash(__name__)
+        app.scripts.config.serve_locally = True
+
+        app.layout = html.Div([
+            dcc.Input(id='a_in', value='a'),
+            dcc.Input(id='b_in', value='b'),
+            html.P('', id='a_out'),
+            html.P('', id='a_out_short'),
+            html.P('', id='b_out'),
+            html.P('', id='ab_out')
+        ])
+
+        @app.callback([Output('a_out', 'children'),
+                       Output('a_out_short', 'children')],
+                      [Input('a_in', 'value')])
+        def a_out(a):
+            return (a, a if len(a) < 3 else no_update)
+
+        @app.callback(Output('b_out', 'children'), [Input('b_in', 'value')])
+        def b_out(b):
+            return b
+
+        @app.callback(Output('ab_out', 'children'),
+                      [Input('a_out_short', 'children')],
+                      [State('b_out', 'children')])
+        def ab_out(a, b):
+            return a + ' ' + b
+
+        self.startServer(app)
+
+        a_in = self.wait_for_element_by_id('a_in')
+        b_in = self.wait_for_element_by_id('b_in')
+
+        b_in.send_keys('b')
+        a_in.send_keys('a')
+        self.wait_for_text_to_equal('#a_out', 'aa')
+        self.wait_for_text_to_equal('#b_out', 'bb')
+        self.wait_for_text_to_equal('#a_out_short', 'aa')
+        self.wait_for_text_to_equal('#ab_out', 'aa bb')
+
+        b_in.send_keys('b')
+        a_in.send_keys('a')
+        self.wait_for_text_to_equal('#a_out', 'aaa')
+        self.wait_for_text_to_equal('#b_out', 'bbb')
+        self.wait_for_text_to_equal('#a_out_short', 'aa')
+        # ab_out has not been triggered because a_out_short received no_update
+        self.wait_for_text_to_equal('#ab_out', 'aa bb')
+
+        b_in.send_keys('b')
+        a_in.send_keys(Keys.END)
+        a_in.send_keys(Keys.BACKSPACE)
+        self.wait_for_text_to_equal('#a_out', 'aa')
+        self.wait_for_text_to_equal('#b_out', 'bbbb')
+        self.wait_for_text_to_equal('#a_out_short', 'aa')
+        # now ab_out *is* triggered - a_out_short got a new value
+        # even though that value is the same as the last value it got
+        self.wait_for_text_to_equal('#ab_out', 'aa bbbb')
+
     def test_with_custom_renderer(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
 
         app.index_string = '''
         <!DOCTYPE html>
@@ -758,7 +854,7 @@ def test_with_custom_renderer_interpolated(self):
             </script>
         '''
 
-        class CustomDash(dash.Dash):
+        class CustomDash(Dash):
 
             def interpolate_index(self, **kwargs):
                 return '''
@@ -824,7 +920,7 @@ def update_output(value):
         self.percy_snapshot(name='request-hooks interpolated')
 
     def test_modified_response(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             dcc.Input(id='input', value='ab'),
             html.Div(id='output')
@@ -832,7 +928,7 @@ def test_modified_response(self):
 
         @app.callback(Output('output', 'children'), [Input('input', 'value')])
         def update_output(value):
-            dash.callback_context.response.set_cookie(
+            callback_context.response.set_cookie(
                 'dash cookie', value + ' - cookie')
             return value + ' - output'
 
@@ -850,7 +946,7 @@ def update_output(value):
         assert_clean_console(self)
 
     def test_late_component_register(self):
-        app = dash.Dash()
+        app = Dash()
 
         app.layout = html.Div([
             html.Button('Click me to put a dcc ', id='btn-insert'),
@@ -874,7 +970,7 @@ def update_output(value):
         self.wait_for_element_by_css_selector('#inserted-input')
 
     def test_output_input_invalid_callback(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             html.Div('child', id='input-output'),
             html.Div(id='out')
@@ -905,7 +1001,7 @@ def failure2(children):
         )
 
     def test_callback_return_validation(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
         app.layout = html.Div([
             html.Div(id='a'),
             html.Div(id='b'),
@@ -942,7 +1038,7 @@ def multi2(a):
             multi2('aaa')
 
     def test_callback_context(self):
-        app = dash.Dash(__name__)
+        app = Dash(__name__)
 
         btns = ['btn-{}'.format(x) for x in range(1, 6)]
 
@@ -956,9 +1052,9 @@ def test_callback_context(self):
         @app.callback(Output('output', 'children'),
                       [Input(x, 'n_clicks') for x in btns])
         def on_click(*args):
-            if not dash.callback_context.triggered:
+            if not callback_context.triggered:
                 raise PreventUpdate
-            trigger = dash.callback_context.triggered[0]
+            trigger = callback_context.triggered[0]
             return 'Just clicked {} for the {} time!'.format(
                 trigger['prop_id'].split('.')[0], trigger['value']
             )
@@ -982,4 +1078,4 @@ def on_click(*args):
     def test_no_callback_context(self):
         for attr in ['inputs', 'states', 'triggered', 'response']:
             with self.assertRaises(MissingCallbackContextException):
-                getattr(dash.callback_context, attr)
+                getattr(callback_context, attr)