diff --git a/circle.yml b/circle.yml index e82ee45..05f4348 100644 --- a/circle.yml +++ b/circle.yml @@ -1,16 +1,15 @@ machine: + pre: + - echo export PERCY_PARALLEL_NONCE=$CIRCLE_BUILD_NUM >> $HOME/.circlerc + node: version: 6.9.2 post: - - pyenv global 2.7.10 3.3.6 3.4.4 3.5.3 3.6.2 + - pyenv global 2.7.10 3.6.2 environment: TOX_PYTHON_27: python2.7 - TOX_PYTHON_33: python3.3 - TOX_PYTHON_34: python3.4 - TOX_PYTHON_35: python3.5 TOX_PYTHON_36: python3.6 - dependencies: pre: - pip install tox diff --git a/dash_renderer/__init__.py b/dash_renderer/__init__.py index d9005d0..0b15ec2 100644 --- a/dash_renderer/__init__.py +++ b/dash_renderer/__init__.py @@ -30,9 +30,9 @@ { 'relative_package_path': 'bundle.js', "external_url": ( - 'https://unpkg.com/dash-renderer@{}' + 'https://unpkg.com/dash-renderer@0.10.0-rc1' '/dash_renderer/bundle.js' - ).format(__version__), + ), 'namespace': 'dash_renderer' } ] diff --git a/dash_renderer/version.py b/dash_renderer/version.py index e4e49b3..366d751 100644 --- a/dash_renderer/version.py +++ b/dash_renderer/version.py @@ -1 +1 @@ -__version__ = '0.9.0' +__version__ = '0.10.0rc1' diff --git a/dev-requirements.txt b/dev-requirements.txt index 0055fd7..df68515 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ dash_core_components==0.12.0 dash_html_components==0.7.0 -dash==0.18.0 +dash==0.18.3 percy selenium mock diff --git a/package.json b/package.json index 0ae02e3..d17b6fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dash-renderer", - "version": "0.9.0", + "version": "0.10.0-rc1", "description": "render dash components in react", "main": "src/index.js", "scripts": { diff --git a/src/actions/index.js b/src/actions/index.js index 956334f..6da1100 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -7,7 +7,9 @@ import { isEmpty, keys, lensPath, + pluck, reject, + slice, sort, type, union, @@ -40,10 +42,10 @@ function triggerDefaultState(dispatch, getState) { const {graphs} = getState(); const {InputGraph} = graphs; const allNodes = InputGraph.overallOrder(); + const inputNodeIds = []; allNodes.reverse(); allNodes.forEach(nodeId => { - const [componentId, componentProp] = nodeId.split('.'); - + const componentId = nodeId.split('.')[0]; /* * Filter out the outputs, * inputs that aren't leaves, @@ -53,24 +55,28 @@ function triggerDefaultState(dispatch, getState) { InputGraph.dependantsOf(nodeId).length == 0 && has(componentId, getState().paths) ) { + inputNodeIds.push(nodeId); + } + }); - // Get the initial property - const propLens = lensPath( - concat(getState().paths[componentId], - ['props', componentProp] - )); - const propValue = view( - propLens, - getState().layout - ); - - dispatch(notifyObservers({ - id: componentId, - props: {[componentProp]: propValue} - })); + reduceInputIds(inputNodeIds, InputGraph).forEach(nodeId => { + const [componentId, componentProp] = nodeId.split('.'); + // Get the initial property + const propLens = lensPath( + concat(getState().paths[componentId], + ['props', componentProp] + )); + const propValue = view( + propLens, + getState().layout + ); - } + dispatch(notifyObservers({ + id: componentId, + props: {[componentProp]: propValue} + })); }); + } export function redo() { @@ -116,6 +122,31 @@ export function undo() { +function reduceInputIds(nodeIds, InputGraph) { + /* + * Create input-output(s) pairs, + * sort by number of outputs, + * and remove redudant inputs (inputs that update the same output) + */ + const inputOutputPairs = nodeIds.map(nodeId => ({ + input: nodeId, + outputs: InputGraph.dependenciesOf(nodeId) + })); + + const sortedInputOutputPairs = sort( + (a, b) => b.outputs.length - a.outputs.length, + inputOutputPairs + ); + + const uniquePairs = sortedInputOutputPairs.filter((pair, i) => !contains( + pair.outputs, + pluck('outputs', slice(i + 1, Infinity, sortedInputOutputPairs)) + )); + + return pluck('input', uniquePairs); +} + + export function notifyObservers(payload) { return function (dispatch, getState) { @@ -136,7 +167,6 @@ export function notifyObservers(payload) { const {EventGraph, InputGraph} = graphs; /* - * Figure out all of the output id's that depend on this * event or input. * This includes id's that are direct children as well as @@ -394,7 +424,7 @@ export function notifyObservers(payload) { * We don't need to do this - just need * to compute the subtree */ - const newProps = []; + const newProps = {}; crawlLayout( observerUpdatePayload.props.children, function appendIds(child) { @@ -404,7 +434,7 @@ export function notifyObservers(payload) { `${child.props.id}.${childProp}` ); if (has(inputId, InputGraph.nodes)) { - newProps.push({ + newProps[inputId] = ({ id: child.props.id, props: { [childProp]: child.props[childProp] @@ -416,21 +446,20 @@ export function notifyObservers(payload) { } ); + /* + * Organize props by shared outputs so that we + * only make one request per output component + * (even if there are multiple inputs). + */ + const reducedNodeIds = reduceInputIds( + keys(newProps), InputGraph); const depOrder = InputGraph.overallOrder(); const sortedNewProps = sort((a, b) => - depOrder.indexOf(a.id) - depOrder.indexOf(b.id), - newProps + depOrder.indexOf(a) - depOrder.indexOf(b), + reducedNodeIds ); - - /* - * TODO - As in the case of Jack Luo's indicator app, - * all of these inputs could update a _single_ output. - * If that is the case, then we can collect all of their - * values and make a single request instead of making a - * different request for each input - */ - sortedNewProps.forEach(function(propUpdate) { - dispatch(notifyObservers(propUpdate)); + sortedNewProps.forEach(function(nodeId) { + dispatch(notifyObservers(newProps[nodeId])); }); } diff --git a/tests/IntegrationTests.py b/tests/IntegrationTests.py index 4951e40..2730336 100644 --- a/tests/IntegrationTests.py +++ b/tests/IntegrationTests.py @@ -9,12 +9,25 @@ import percy import time import unittest +import os +import sys class IntegrationTests(unittest.TestCase): + def percy_snapshot(cls, name=''): + snapshot_name = '{} - {}'.format(name, sys.version_info) + print(snapshot_name) + cls.percy_runner.snapshot( + name=snapshot_name + ) + @classmethod def setUpClass(cls): + print('PERCY_PARALLEL_NONCE') + print(os.environ['PERCY_PARALLEL_NONCE']) + print('PERCY_PARALLEL_TOTAL') + print(os.environ['PERCY_PARALLEL_TOTAL']) super(IntegrationTests, cls).setUpClass() cls.driver = webdriver.Chrome() @@ -25,6 +38,7 @@ def setUpClass(cls): cls.percy_runner.initialize_build() + @classmethod def tearDownClass(cls): super(IntegrationTests, cls).tearDownClass() diff --git a/tests/test_render.py b/tests/test_render.py index 9ca4265..093313d 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -9,6 +9,7 @@ import time import re import itertools +import json class Tests(IntegrationTests): @@ -391,7 +392,7 @@ def test_initial_state(self): [] ) - self.percy_runner.snapshot(name='layout') + self.percy_snapshot(name='layout') assert_clean_console(self) @@ -423,7 +424,7 @@ def update_output(value): output1 = self.wait_for_element_by_id('output-1') wait_for(lambda: output1.text == 'initial value') - self.percy_runner.snapshot(name='simple-callback-1') + self.percy_snapshot(name='simple-callback-1') input1 = self.wait_for_element_by_id('input') input1.clear() @@ -432,7 +433,7 @@ def update_output(value): output1 = lambda: self.wait_for_element_by_id('output-1') wait_for(lambda: output1().text == 'hello world') - self.percy_runner.snapshot(name='simple-callback-2') + self.percy_snapshot(name='simple-callback-2') self.assertEqual( call_count.value, @@ -510,7 +511,7 @@ def update_input(value): '''.replace('\n', '').replace(' ', '') ) ) - self.percy_runner.snapshot(name='callback-generating-function-1') + self.percy_snapshot(name='callback-generating-function-1') # the paths should include these new output IDs self.assertEqual( @@ -550,7 +551,7 @@ def update_input(value): ), [] ) - self.percy_runner.snapshot(name='callback-generating-function-2') + self.percy_snapshot(name='callback-generating-function-2') assert_clean_console(self) @@ -766,7 +767,7 @@ def chapter1_assertions(): generic_chapter_assertions('chapter1') chapter1_assertions() - self.percy_runner.snapshot(name='chapter-1') + self.percy_snapshot(name='chapter-1') # switch chapters (self.driver.find_elements_by_css_selector( @@ -775,7 +776,7 @@ def chapter1_assertions(): # sleep just to make sure that no calls happen after our check time.sleep(2) - self.percy_runner.snapshot(name='chapter-2') + self.percy_snapshot(name='chapter-2') wait_for(lambda: call_counts['body'].value == 2) wait_for(lambda: call_counts['chapter2-graph'].value == 1) wait_for(lambda: call_counts['chapter2-label'].value == 1) @@ -820,7 +821,7 @@ def chapter2_assertions(): )[2]).click() # sleep just to make sure that no calls happen after our check time.sleep(2) - self.percy_runner.snapshot(name='chapter-3') + self.percy_snapshot(name='chapter-3') wait_for(lambda: call_counts['body'].value == 3) wait_for(lambda: call_counts['chapter3-graph'].value == 1) wait_for(lambda: call_counts['chapter3-label'].value == 1) @@ -873,7 +874,7 @@ def chapter3_assertions(): self.driver.find_element_by_id('body').text == 'Just a string' )) - self.percy_runner.snapshot(name='chapter-4') + self.percy_snapshot(name='chapter-4') # each element should exist in the dom paths = self.driver.execute_script( @@ -892,7 +893,7 @@ def chapter3_assertions(): )[0]).click() time.sleep(0.5) chapter1_assertions() - self.percy_runner.snapshot(name='chapter-1-again') + self.percy_snapshot(name='chapter-1-again') # switch to 5 (self.driver.find_elements_by_css_selector( @@ -910,7 +911,7 @@ def chapter3_assertions(): chapter5_button().click() wait_for(lambda: chapter5_div().text == chapter5_output_children) time.sleep(0.5) - self.percy_runner.snapshot(name='chapter-5') + self.percy_snapshot(name='chapter-5') self.assertEqual(call_counts['chapter5-output'].value, 1) def test_dependencies_on_components_that_dont_exist(self): @@ -945,7 +946,7 @@ def update_output_2(value): el = self.wait_for_element_by_id('output-1') wait_for(lambda: el.text == 'initial value') - self.percy_runner.snapshot(name='dependencies') + self.percy_snapshot(name='dependencies') time.sleep(1.0) self.assertEqual(output_1_call_count.value, 1) self.assertEqual(output_2_call_count.value, 0) @@ -1415,3 +1416,88 @@ def chapter2_assertions(): time.sleep(2) # liberally wait for the front-end to process request chapter2_assertions() assert_clean_console(self) + + def test_rendering_layout_calls_callback_once_per_output(self): + app = Dash(__name__) + call_count = Value('i', 0) + + app.config['suppress_callback_exceptions'] = True + app.layout = html.Div([ + html.Div([ + dcc.Input( + value='Input {}'.format(i), + id='input-{}'.format(i) + ) + for i in range(10) + ]), + html.Div(id='container'), + dcc.RadioItems() + ]) + + @app.callback( + Output('container', 'children'), + [Input('input-{}'.format(i), 'value') for i in range(10)]) + def dynamic_output(*args): + call_count.value += 1 + return json.dumps(args, indent=2) + + self.startServer(app) + + time.sleep(5) + + self.percy_snapshot( + name='test_rendering_layout_calls_callback_once_per_output' + ) + + self.assertEqual(call_count.value, 1) + + def test_rendering_new_content_calls_callback_once_per_output(self): + app = Dash(__name__) + call_count = Value('i', 0) + + app.config['suppress_callback_exceptions'] = True + app.layout = html.Div([ + html.Button( + id='display-content', + children='Display Content', + n_clicks=0 + ), + html.Div(id='container'), + dcc.RadioItems() + ]) + + @app.callback( + Output('container', 'children'), + [Input('display-content', 'n_clicks')]) + def display_output(n_clicks): + if n_clicks == 0: + return '' + return html.Div([ + html.Div([ + dcc.Input( + value='Input {}'.format(i), + id='input-{}'.format(i) + ) + for i in range(10) + ]), + html.Div(id='dynamic-output') + ]) + + @app.callback( + Output('dynamic-output', 'children'), + [Input('input-{}'.format(i), 'value') for i in range(10)]) + def dynamic_output(*args): + call_count.value += 1 + return json.dumps(args, indent=2) + + self.startServer(app) + + self.wait_for_element_by_id('display-content').click() + + time.sleep(5) + + self.percy_snapshot( + name='test_rendering_new_content_calls_callback_once_per_output' + ) + + self.assertEqual(call_count.value, 1) diff --git a/tox.ini b/tox.ini index 1462ab1..d448499 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py33,py34,py35 +envlist = py27,py36 [testenv] deps = -rdev-requirements.txt @@ -7,35 +7,20 @@ deps = -rdev-requirements.txt passenv = * [testenv:py27] -basepython={env:TOX_PYTHON_27} -commands = - python --version - python -m unittest tests.test_render.Tests - python -m unittest tests.test_race_conditions.Tests - -[testenv:py33] -basepython={env:TOX_PYTHON_33} -commands = - python --version - python -m unittest tests.test_render.Tests - python -m unittest tests.test_race_conditions.Tests - -[testenv:py34] -basepython={env:TOX_PYTHON_34} -commands = - python --version - python -m unittest tests.test_render.Tests - python -m unittest tests.test_race_conditions.Tests - -[testenv:py35] -basepython={env:TOX_PYTHON_35} +basepython = + {env:TOX_PYTHON_27} +setenv = + PERCY_PARALLEL_TOTAL=4 commands = python --version python -m unittest tests.test_render.Tests python -m unittest tests.test_race_conditions.Tests [testenv:py36] -basepython={env:TOX_PYTHON_36} +basepython = + {env:TOX_PYTHON_36} +setenv = + PERCY_PARALLEL_TOTAL=4 commands = python --version python -m unittest tests.test_render.Tests