diff --git a/@plotly/dash-test-components/src/components/RenderType.js b/@plotly/dash-test-components/src/components/RenderType.js new file mode 100644 index 0000000000..aa99f1feb8 --- /dev/null +++ b/@plotly/dash-test-components/src/components/RenderType.js @@ -0,0 +1,23 @@ +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; + +const RenderType = (props) => { + const onClick = () => { + props.setProps({n_clicks: (props.n_clicks || 0) + 1}) + } + + return
+ {props.dashRenderType} + +
; +}; + +RenderType.propTypes = { + id: PropTypes.string, + dashRenderType: PropTypes.string, + n_clicks: PropTypes.number, + setProps: PropTypes.func +}; + +RenderType.dashRenderType = true; +export default RenderType; diff --git a/@plotly/dash-test-components/src/index.js b/@plotly/dash-test-components/src/index.js index f72bfd0521..9a6523b22c 100644 --- a/@plotly/dash-test-components/src/index.js +++ b/@plotly/dash-test-components/src/index.js @@ -7,6 +7,7 @@ import MyPersistedComponentNested from './components/MyPersistedComponentNested' import StyledComponent from './components/StyledComponent'; import WidthComponent from './components/WidthComponent'; import ComponentAsProp from './components/ComponentAsProp'; +import RenderType from './components/RenderType'; import DrawCounter from './components/DrawCounter'; import AddPropsComponent from "./components/AddPropsComponent"; @@ -32,4 +33,5 @@ export { ShapeOrExactKeepOrderComponent, ArrayOfExactOrShapeWithNodePropAssignNone, ExternalComponent, + RenderType }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 6247e0a0d3..6681cbaef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,17 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Changed - [#3113](https://github.com/plotly/dash/pull/3113) Adjusted background polling requests to strip the data from the request, this allows for context to flow as normal. This addresses issue [#3111](https://github.com/plotly/dash/pull/3111) +- [#3248](https://github.com/plotly/dash/pull/3248) Changes to rendering logic: + - if it is first time rendering, render from the parent props + - listens only to updates for that single component, no children listening to parents + - if parents change a prop with components as props, only the prop changed re-renders, this is then forced on all children regardless of whether or not the props changed ## Fixed - [#3251](https://github.com/plotly/dash/pull/3251). Prevented default styles from overriding `className_*` props in `dcc.Upload` component. +## Added +- [#3248](https://github.com/plotly/dash/pull/3248) added new `dashRenderType` to determine why the component layout was changed (`internal`, `callback`, `parent`, `clientsideApi`): + - this can be utilized to keep from rendering components by the component having `dashRenderType` defined as a prop, and the `dashRenderType = true` must be set on the component, eg (`Div.dashRenderType = true`) ## [3.0.1] - 2025-03-24 diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index a61dfd6159..9765c8bba1 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -360,7 +360,8 @@ function updateComponent(component_id: any, props: any, cb: ICallbackPayload) { dispatch( updateProps({ props, - itempath: componentPath + itempath: componentPath, + renderType: 'callback' }) ); dispatch(notifyObservers({id: component_id, props})); diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 1249252704..f82a24bdf0 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -63,12 +63,12 @@ const observer: IStoreObserverDefinition = { // In case the update contains whole components, see if any of // those components have props to update to persist user edits. const {props} = applyPersistence({props: updatedProps}, dispatch); - dispatch( updateProps({ itempath, props, - source: 'response' + source: 'response', + renderType: 'callback' }) ); diff --git a/dash/dash-renderer/src/reducers/reducer.js b/dash/dash-renderer/src/reducers/reducer.js index 908cd87bc6..56d2c82ad2 100644 --- a/dash/dash-renderer/src/reducers/reducer.js +++ b/dash/dash-renderer/src/reducers/reducer.js @@ -27,7 +27,7 @@ export const apiRequests = [ 'loginRequest' ]; -function layoutHashes(state = {}, action) { +const layoutHashes = (state = {}, action) => { if ( includes(action.type, [ 'UNDO_PROP_CHANGE', @@ -37,12 +37,21 @@ function layoutHashes(state = {}, action) { ) { // Let us compare the paths sums to get updates without triggering // render on the parent containers. - const strPath = stringifyPath(action.payload.itempath); - const prev = pathOr(0, [strPath], state); - return assoc(strPath, prev + 1, state); + const actionPath = action.payload.itempath; + const strPath = stringifyPath(actionPath); + const prev = pathOr(0, [strPath, 'hash'], state); + state = assoc( + strPath, + { + hash: prev + 1, + changedProps: action.payload.props, + renderType: action.payload.renderType + }, + state + ); } return state; -} +}; function mainReducer() { const parts = { diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index f266c72790..4859df41bf 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -16,8 +16,8 @@ function set_props( for (let y = 0; y < ds.length; y++) { const {dispatch, getState} = ds[y]; let componentPath; + const {paths} = getState(); if (!Array.isArray(idOrPath)) { - const {paths} = getState(); componentPath = getPath(paths, idOrPath); } else { componentPath = idOrPath; @@ -25,7 +25,8 @@ function set_props( dispatch( updateProps({ props, - itempath: componentPath + itempath: componentPath, + renderType: 'clientsideApi' }) ); dispatch(notifyObservers({id: idOrPath, props})); diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 86c34cc7c6..82bee5b0ba 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useCallback, memo} from 'react'; +import React, {useCallback, MutableRefObject, useRef, useMemo} from 'react'; import { path, concat, @@ -24,7 +24,12 @@ import {DashConfig} from '../config'; import {notifyObservers, onError, updateProps} from '../actions'; import {getWatchedKeys, stringifyId} from '../actions/dependencies'; import {recordUiEdit} from '../persistence'; -import {createElement, getComponentLayout, isDryComponent} from './wrapping'; +import { + createElement, + getComponentLayout, + isDryComponent, + checkRenderTypeProp +} from './wrapping'; import Registry from '../registry'; import isSimpleComponent from '../isSimpleComponent'; import { @@ -41,26 +46,54 @@ type DashWrapperProps = { */ componentPath: DashLayoutPath; _dashprivate_error?: any; + _passedComponent?: any; + _newRender?: any; +}; + +// Define a type for the memoized keys +type MemoizedKeysType = { + [key: string]: React.ReactNode | null; // This includes React elements, strings, numbers, etc. }; function DashWrapper({ componentPath, _dashprivate_error, + _passedComponent, // pass component to the DashWrapper in the event that it is a newRender and there are no layouthashes + _newRender, // this is to force the component to newly render regardless of props (redraw and component as props) is passed from the parent ...extras }: DashWrapperProps) { const dispatch = useDispatch(); + const memoizedKeys: MutableRefObject = useRef({}); + const newRender = useRef(false); + let renderComponent: any = null; + let renderComponentProps: any = null; + let renderH: any = null; // Get the config for the component as props const config: DashConfig = useSelector(selectConfig); - // Select both the component and it's props. - const [component, componentProps] = useSelector( - selectDashProps(componentPath), - selectDashPropsEqualityFn - ); + // Select component and it's props, along with render hash, changed props and the reason for render + const [component, componentProps, h, changedProps, renderType] = + useSelector(selectDashProps(componentPath), selectDashPropsEqualityFn); + + renderComponent = component; + renderComponentProps = componentProps; + renderH = h; + + useMemo(() => { + if (_newRender) { + newRender.current = true; + renderH = 0; + if (renderH in memoizedKeys.current) { + delete memoizedKeys.current[renderH]; + } + } else { + newRender.current = false; + } + }, [_newRender]); const setProps = (newProps: UpdatePropsPayload) => { - const {id} = componentProps; + const {id} = renderComponentProps; const {_dash_error, ...restProps} = newProps; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -68,11 +101,10 @@ function DashWrapper({ dispatch((dispatch, getState) => { const currentState = getState(); const {graphs} = currentState; - - const {props: oldProps} = getComponentLayout( - componentPath, - currentState - ); + const oldLayout = getComponentLayout(componentPath, currentState); + if (!oldLayout) return; + const {props: oldProps} = oldLayout; + if (!oldProps) return; const changedProps = pickBy( (val, key) => !equals(val, oldProps[key]), restProps @@ -96,7 +128,7 @@ function DashWrapper({ batch(() => { // setProps here is triggered by the UI - record these changes // for persistence - recordUiEdit(component, newProps, dispatch); + recordUiEdit(renderComponent, newProps, dispatch); // Only dispatch changes to Dash if a watched prop changed if (watchedKeys.length) { @@ -112,7 +144,8 @@ function DashWrapper({ dispatch( updateProps({ props: changedProps, - itempath: componentPath + itempath: componentPath, + renderType: 'internal' }) ); }); @@ -120,9 +153,9 @@ function DashWrapper({ }; const createContainer = useCallback( - (container, containerPath, key = undefined) => { - if (isSimpleComponent(component)) { - return component; + (container, containerPath, _childNewRender, key = undefined) => { + if (isSimpleComponent(renderComponent)) { + return renderComponent; } return ( ); }, @@ -141,7 +176,7 @@ function DashWrapper({ ); const wrapChildrenProp = useCallback( - (node: any, childrenProp: DashLayoutPath) => { + (node: any, childrenPath: DashLayoutPath, _childNewRender: any) => { if (Array.isArray(node)) { return node.map((n, i) => { if (isDryComponent(n)) { @@ -149,9 +184,10 @@ function DashWrapper({ n, concat(componentPath, [ 'props', - ...childrenProp, + ...childrenPath, i ]), + _childNewRender, i ); } @@ -163,7 +199,8 @@ function DashWrapper({ } return createContainer( node, - concat(componentPath, ['props', ...childrenProp]) + concat(componentPath, ['props', ...childrenPath]), + _childNewRender ); }, [componentPath] @@ -172,25 +209,42 @@ function DashWrapper({ const extraProps = { setProps, ...extras - }; + } as {[key: string]: any}; - const element = useMemo(() => Registry.resolve(component), [component]); + if (checkRenderTypeProp(renderComponent)) { + extraProps['dashRenderType'] = newRender.current + ? 'parent' + : changedProps + ? renderType + : 'parent'; + } - const hydratedProps = useMemo(() => { + const setHydratedProps = (component: any, componentProps: any) => { // Hydrate components props const childrenProps = pathOr( [], - ['children_props', component.namespace, component.type], + ['children_props', component?.namespace, component?.type], config ); let props = mergeRight(dissoc('children', componentProps), extraProps); for (let i = 0; i < childrenProps.length; i++) { const childrenProp: string = childrenProps[i]; - + let childNewRender: any = 0; + if ( + childrenProp + .split('.')[0] + .replace('[]', '') + .replace('{}', '') in changedProps || + newRender.current || + !renderH + ) { + childNewRender = {}; + } const handleObject = (obj: any, opath: DashLayoutPath) => { return mapObjIndexed( - (node, k) => wrapChildrenProp(node, [...opath, k]), + (node, k) => + wrapChildrenProp(node, [...opath, k], childNewRender), obj ); }; @@ -260,7 +314,8 @@ function DashWrapper({ } else { listValue = wrapChildrenProp( path(backPath, el), - elementPath + elementPath, + childNewRender ); } return assocPath(backPath, listValue, el); @@ -306,7 +361,8 @@ function DashWrapper({ dynamic, concat([k], backPath) ) - : concat(dynamic, [k]) + : concat(dynamic, [k]), + childNewRender ), dynValue ); @@ -317,7 +373,11 @@ function DashWrapper({ if (node === undefined) { continue; } - nodeValue = wrapChildrenProp(node, childrenPath); + nodeValue = wrapChildrenProp( + node, + childrenPath, + childNewRender + ); } } props = assocPath(childrenPath, nodeValue, props); @@ -353,7 +413,11 @@ function DashWrapper({ if (node !== undefined) { props = assoc( childrenProp, - wrapChildrenProp(node, [childrenProp]), + wrapChildrenProp( + node, + [childrenProp], + childNewRender + ), props ); } @@ -367,84 +431,82 @@ function DashWrapper({ props.id = stringifyId(props.id); } return props; - }, [componentProps]); + }; - const hydrated = useMemo(() => { - let hydratedChildren: any; - if (componentProps.children !== undefined) { - hydratedChildren = wrapChildrenProp(componentProps.children, [ - 'children' - ]); + const hydrateFunc = () => { + if (newRender.current) { + renderComponent = _passedComponent; + renderComponentProps = _passedComponent?.props; } - if (config.props_check) { - return ( - - {createElement( - element, - hydratedProps, - extraProps, - hydratedChildren - )} - + if (!renderComponent) { + return null; + } + + const element = Registry.resolve(renderComponent); + const hydratedProps = setHydratedProps( + renderComponent, + renderComponentProps + ); + + let hydratedChildren: any; + if (renderComponentProps.children !== undefined) { + hydratedChildren = wrapChildrenProp( + renderComponentProps.children, + ['children'], + !renderH || newRender.current || 'children' in changedProps + ? {} + : 0 ); } + newRender.current = false; - return createElement( - element, - hydratedProps, - extraProps, - hydratedChildren + return config.props_check ? ( + + {createElement( + element, + hydratedProps, + extraProps, + hydratedChildren + )} + + ) : ( + createElement(element, hydratedProps, extraProps, hydratedChildren) ); - }, [ - element, - component, - hydratedProps, - extraProps, - wrapChildrenProp, - componentProps, - config.props_check - ]); - - return ( + }; + + let hydrated = null; + if (renderH in memoizedKeys.current && !newRender.current) { + hydrated = React.isValidElement(memoizedKeys.current[renderH]) + ? memoizedKeys.current[renderH] + : null; + } + if (!hydrated) { + hydrated = hydrateFunc(); + memoizedKeys.current = {[renderH]: hydrated}; + } + + return renderComponent ? ( - {hydrated} + {React.isValidElement(hydrated) ? hydrated :
} + ) : ( +
); } -function wrapperEquality(prev: any, next: any) { - const { - componentPath: prevPath, - _dashprivate_error: prevError, - ...prevProps - } = prev; - const { - componentPath: nextPath, - _dashprivate_error: nextError, - ...nextProps - } = next; - if (JSON.stringify(prevPath) !== JSON.stringify(nextPath)) { - return false; - } - if (prevError !== nextError) { - return false; - } - return equals(prevProps, nextProps); -} - -export default memo(DashWrapper, wrapperEquality); +export default DashWrapper; diff --git a/dash/dash-renderer/src/wrapper/selectors.ts b/dash/dash-renderer/src/wrapper/selectors.ts index 293af71264..46930bc47e 100644 --- a/dash/dash-renderer/src/wrapper/selectors.ts +++ b/dash/dash-renderer/src/wrapper/selectors.ts @@ -1,7 +1,7 @@ import {DashLayoutPath, DashComponent, BaseDashProps} from '../types/component'; import {getComponentLayout, stringifyPath} from './wrapping'; -type SelectDashProps = [DashComponent, BaseDashProps, number]; +type SelectDashProps = [DashComponent, BaseDashProps, number, object, string]; export const selectDashProps = (componentPath: DashLayoutPath) => @@ -12,14 +12,16 @@ export const selectDashProps = // Then it can be easily compared without having to compare the props. const strPath = stringifyPath(componentPath); - const h = Object.entries(state.layoutHashes).reduce( - (acc, [updatedPath, pathHash]) => - strPath.startsWith(updatedPath) - ? (pathHash as number) + acc - : acc, - 0 - ); - return [c, c.props, h]; + const hash = state.layoutHashes[strPath]; + let h = 0; + let changedProps: object = {}; + let renderType = ''; + if (hash) { + h = hash['hash']; + changedProps = hash['changedProps']; + renderType = hash['renderType']; + } + return [c, c?.props, h, changedProps, renderType]; }; export function selectDashPropsEqualityFn( diff --git a/dash/dash-renderer/src/wrapper/wrapping.ts b/dash/dash-renderer/src/wrapper/wrapping.ts index baf0e4f6f3..37d2c461ea 100644 --- a/dash/dash-renderer/src/wrapper/wrapping.ts +++ b/dash/dash-renderer/src/wrapper/wrapping.ts @@ -1,5 +1,5 @@ import React from 'react'; -import {mergeRight, path, type, has, join} from 'ramda'; +import {mergeRight, path, type, has, join, pathOr} from 'ramda'; import {DashComponent, DashLayoutPath} from '../types/component'; export function createElement( @@ -61,3 +61,14 @@ export function getComponentLayout( ): DashComponent { return path(componentPath, state.layout) as DashComponent; } + +export function checkRenderTypeProp(componentDefinition: any) { + return ( + 'dashRenderType' in + pathOr( + {}, + [componentDefinition?.namespace, componentDefinition?.type], + window as any + ) + ); +} diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 9bfd5d0b6f..96fa938f2f 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -25,138 +25,139 @@ def opt(u): def test_rdcap001_component_as_prop(dash_duo): app = Dash(__name__) - app.layout = Div( - [ - ComponentAsProp( - element=Div( - "as-props", - id="as-props", - ) - ), - ComponentAsProp( - id="clicker-container", element=Button("click-me", id="clicker") - ), - ComponentAsProp( - id="nested-output-container", - element=Div(id="nested-output"), - ), - Div( - [ - Button("click-nested", id="send-nested"), - Div(id="output-from-prop"), - ] - ), - ComponentAsProp( - id="elements", - element=[ - Div("one", id="list-one"), - Div("two", id="list-two"), - Div(id="list-output"), - ], - ), - Div( - [ - Button("click-list", id="to-list"), - Div(id="output-from-list"), - Button("click footer", id="to-footer"), - Div(id="from-header"), - Div(id="from-list-of-dict"), - Button("click to list", id="update-list-of-dict"), - ], - ), - ComponentAsProp( - id="shaped", - shapeEl={ - "header": Button("header", id="button-header"), - "footer": Div("initial", id="footer"), + content = [ + ComponentAsProp( + element=Div( + "as-props", + id="as-props", + ) + ), + ComponentAsProp( + id="clicker-container", element=Button("click-me", id="clicker") + ), + ComponentAsProp( + id="nested-output-container", + element=Div(id="nested-output"), + ), + Div( + [ + Button("click-nested", id="send-nested"), + Div(id="output-from-prop"), + ] + ), + ComponentAsProp( + id="elements", + element=[ + Div("one", id="list-one"), + Div("two", id="list-two"), + Div(id="list-output"), + ], + ), + Div( + [ + Button("click-list", id="to-list"), + Div(id="output-from-list"), + Button("click footer", id="to-footer"), + Div(id="from-header"), + Div(id="from-list-of-dict"), + Button("click to list", id="update-list-of-dict"), + ], + ), + ComponentAsProp( + id="shaped", + shapeEl={ + "header": Button("header", id="button-header"), + "footer": Div("initial", id="footer"), + }, + ), + ComponentAsProp( + id="list-of-dict", + list_of_shapes=[ + {"label": Button(f"click-{i}", id=f"list-click-{i}"), "value": i} + for i in range(1, 4) + ], + ), + ComponentAsProp( + "list-of-dict-update", + list_of_shapes=[ + { + "label": Div("update me", id="update-in-list-of-dict"), + "value": 1, }, - ), - ComponentAsProp( - id="list-of-dict", - list_of_shapes=[ - {"label": Button(f"click-{i}", id=f"list-click-{i}"), "value": i} - for i in range(1, 4) - ], - ), - ComponentAsProp( - "list-of-dict-update", - list_of_shapes=[ - { - "label": Div("update me", id="update-in-list-of-dict"), - "value": 1, - }, - ], - ), - ComponentAsProp( - id="list-of-list-of-nodes", - list_of_shapes=[ - { - "label": [ - Div("first-label", id="first-label"), - Div("second-label", id="second-label"), - ], - "value": 2, - } - ], - ), - ComponentAsProp( - id="list-in-shape", - shapeEl={ - "header": [ - Div("one", id="first-in-shape"), - Div("two", id="second-in-shape"), - ] + ], + ), + ComponentAsProp( + id="list-of-list-of-nodes", + list_of_shapes=[ + { + "label": [ + Div("first-label", id="first-label"), + Div("second-label", id="second-label"), + ], + "value": 2, + } + ], + ), + ComponentAsProp( + id="list-in-shape", + shapeEl={ + "header": [ + Div("one", id="first-in-shape"), + Div("two", id="second-in-shape"), + ] + }, + ), + ComponentAsProp( + id="multi-component", + multi_components=[ + { + "id": "multi", + "first": Span("first"), + "second": Span("second"), }, - ), - ComponentAsProp( - id="multi-component", - multi_components=[ - { - "id": "multi", - "first": Span("first"), - "second": Span("second"), - }, - { - "id": "multi2", - "first": Span("foo"), - "second": Span("bar"), - }, - ], - ), - ComponentAsProp( - dynamic={ - "inside-dynamic": Div("dynamic", "inside-dynamic"), - "output-dynamic": Div(id="output-dynamic"), - "clicker": Button("click-dynamic", id="click-dynamic"), - "clicker-dict": Button("click-dict", id="click-dict"), - "clicker-list": Button("click-list", id="click-list"), - "clicker-nested": Button("click-nested", id="click-nested"), + { + "id": "multi2", + "first": Span("foo"), + "second": Span("bar"), }, - dynamic_dict={ - "node": { - "dict-dyn": Div("dict-dyn", id="inside-dict"), - "dict-2": Div("dict-2", id="inside-dict-2"), - } + ], + ), + ComponentAsProp( + dynamic={ + "inside-dynamic": Div("dynamic", "inside-dynamic"), + "output-dynamic": Div(id="output-dynamic"), + "clicker": Button("click-dynamic", id="click-dynamic"), + "clicker-dict": Button("click-dict", id="click-dict"), + "clicker-list": Button("click-list", id="click-list"), + "clicker-nested": Button("click-nested", id="click-nested"), + }, + dynamic_dict={ + "node": { + "dict-dyn": Div("dict-dyn", id="inside-dict"), + "dict-2": Div("dict-2", id="inside-dict-2"), + } + }, + dynamic_list=[ + { + "list": Div("dynamic-list", id="inside-list"), + "list-2": Div("list-2", id="inside-list-2"), }, - dynamic_list=[ - { - "list": Div("dynamic-list", id="inside-list"), - "list-2": Div("list-2", id="inside-list-2"), + {"list-3": Div("list-3", id="inside-list-3")}, + ], + dynamic_nested_list=[ + {"obj": {"nested": Div("nested", id="nested-dyn")}}, + { + "obj": { + "nested": Div("nested-2", id="nested-2"), + "nested-again": Div("nested-again", id="nested-again"), }, - {"list-3": Div("list-3", id="inside-list-3")}, - ], - dynamic_nested_list=[ - {"obj": {"nested": Div("nested", id="nested-dyn")}}, - { - "obj": { - "nested": Div("nested-2", id="nested-2"), - "nested-again": Div("nested-again", id="nested-again"), - }, - }, - ], - ), - ] - ) + }, + ], + ), + Button(id="reset_test"), + ] + + app.layout = Div(content, id="test_container") @app.callback( Output("output-from-prop", "children"), [Input("clicker", "n_clicks")] @@ -237,80 +238,94 @@ def on_click(n_clicks): def on_click(n_clicks): return f"Clicked {n_clicks}" + @app.callback( + Output("test_container", "children"), + Input("reset_test", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + return content + dash_duo.start_server(app) - assert dash_duo.get_logs() == [] + for i in range(3): + ## tests re-rendering of components as props outside of the normal layout or first-time render + assert dash_duo.get_logs() == [] - dash_duo.wait_for_text_to_equal("#as-props", "as-props") + dash_duo.wait_for_text_to_equal("#as-props", "as-props") - elements = dash_duo.find_elements("#elements div") + elements = dash_duo.find_elements("#elements div") - assert len(elements) == 3 + assert len(elements) == 3 - clicker = dash_duo.wait_for_element("#clicker") - clicker.click() - dash_duo.wait_for_text_to_equal("#output-from-prop", "From prop: 1") + clicker = dash_duo.wait_for_element("#clicker") + clicker.click() + dash_duo.wait_for_text_to_equal("#output-from-prop", "From prop: 1") - nested = dash_duo.wait_for_element("#send-nested") - nested.click() - dash_duo.wait_for_text_to_equal("#nested-output", "Nested: 1") + nested = dash_duo.wait_for_element("#send-nested") + nested.click() + dash_duo.wait_for_text_to_equal("#nested-output", "Nested: 1") - to_list = dash_duo.find_element("#to-list") - to_list.click() - dash_duo.wait_for_text_to_equal("#list-output", "To list: 1") + to_list = dash_duo.find_element("#to-list") + to_list.click() + dash_duo.wait_for_text_to_equal("#list-output", "To list: 1") - from_list = dash_duo.find_element("#list-two") - from_list.click() - dash_duo.wait_for_text_to_equal("#output-from-list", "From list: 1") + from_list = dash_duo.find_element("#list-two") + from_list.click() + dash_duo.wait_for_text_to_equal("#output-from-list", "From list: 1") - from_header = dash_duo.find_element("#button-header") - from_header.click() - dash_duo.wait_for_text_to_equal("#from-header", "From header: 1") + from_header = dash_duo.find_element("#button-header") + from_header.click() + dash_duo.wait_for_text_to_equal("#from-header", "From header: 1") - to_footer = dash_duo.find_element("#to-footer") - to_footer.click() - dash_duo.wait_for_text_to_equal("#footer", "To footer: 1") + to_footer = dash_duo.find_element("#to-footer") + to_footer.click() + dash_duo.wait_for_text_to_equal("#footer", "To footer: 1") - for btn_id in (f"list-click-{i}" for i in range(1, 4)): - dash_duo.find_element(f"#{btn_id}").click() - dash_duo.wait_for_text_to_equal("#from-list-of-dict", f"{btn_id}.n_clicks") + for btn_id in (f"list-click-{i}" for i in range(1, 4)): + dash_duo.find_element(f"#{btn_id}").click() + dash_duo.wait_for_text_to_equal("#from-list-of-dict", f"{btn_id}.n_clicks") - dash_duo.find_element("#update-list-of-dict").click() - dash_duo.wait_for_text_to_equal("#update-in-list-of-dict", "Updated: 1") + dash_duo.find_element("#update-list-of-dict").click() + dash_duo.wait_for_text_to_equal("#update-in-list-of-dict", "Updated: 1") - dash_duo.wait_for_text_to_equal("#first-label", "first-label") - dash_duo.wait_for_text_to_equal("#second-label", "second-label") + dash_duo.wait_for_text_to_equal("#first-label", "first-label") + dash_duo.wait_for_text_to_equal("#second-label", "second-label") - dash_duo.wait_for_text_to_equal("#first-in-shape", "one") - dash_duo.wait_for_text_to_equal("#second-in-shape", "two") + dash_duo.wait_for_text_to_equal("#first-in-shape", "one") + dash_duo.wait_for_text_to_equal("#second-in-shape", "two") - dash_duo.wait_for_text_to_equal("#multi", "first - second") - dash_duo.wait_for_text_to_equal("#multi2", "foo - bar") + dash_duo.wait_for_text_to_equal("#multi", "first - second") + dash_duo.wait_for_text_to_equal("#multi2", "foo - bar") - dash_duo.wait_for_text_to_equal("#inside-dynamic", "dynamic") - dash_duo.wait_for_text_to_equal("#dict-dyn", "dict-dyn") - dash_duo.wait_for_text_to_equal("#inside-dict-2", "dict-2") - dash_duo.wait_for_text_to_equal("#nested-2", "nested-2") - dash_duo.wait_for_text_to_equal("#nested-again", "nested-again") + dash_duo.wait_for_text_to_equal("#inside-dynamic", "dynamic") + dash_duo.wait_for_text_to_equal("#dict-dyn", "dict-dyn") + dash_duo.wait_for_text_to_equal("#inside-dict-2", "dict-2") + dash_duo.wait_for_text_to_equal("#nested-2", "nested-2") + dash_duo.wait_for_text_to_equal("#nested-again", "nested-again") - dash_duo.wait_for_text_to_equal("#inside-list", "dynamic-list") - dash_duo.wait_for_text_to_equal("#inside-list-2", "list-2") - dash_duo.wait_for_text_to_equal("#inside-list-3", "list-3") + dash_duo.wait_for_text_to_equal("#inside-list", "dynamic-list") + dash_duo.wait_for_text_to_equal("#inside-list-2", "list-2") + dash_duo.wait_for_text_to_equal("#inside-list-3", "list-3") - dash_duo.find_element("#click-dynamic").click() - dash_duo.wait_for_text_to_equal("#output-dynamic", "Clicked 1") + dash_duo.find_element("#click-dynamic").click() + dash_duo.wait_for_text_to_equal("#output-dynamic", "Clicked 1") - dash_duo.find_element("#click-dict").click() - dash_duo.wait_for_text_to_equal("#inside-dict", "Clicked 1") + dash_duo.find_element("#click-dict").click() + dash_duo.wait_for_text_to_equal("#inside-dict", "Clicked 1") - dash_duo.find_element("#click-list").click() - dash_duo.wait_for_text_to_equal("#inside-list", "Clicked 1") + dash_duo.find_element("#click-list").click() + dash_duo.wait_for_text_to_equal("#inside-list", "Clicked 1") - dash_duo.find_element("#click-nested").click() - dash_duo.wait_for_text_to_equal("#nested-dyn", "Clicked 1") + dash_duo.find_element("#click-nested").click() + dash_duo.wait_for_text_to_equal("#nested-dyn", "Clicked 1") + assert dash_duo.get_logs() == [] + dash_duo.find_element("#reset_test").click() assert dash_duo.get_logs() == [] + dash_duo.wait_for_text_to_equal("#as-props", "as-props") + def test_rdcap002_component_as_props_dynamic_id(dash_duo): # Test for issue 2296 diff --git a/tests/integration/renderer/test_render_type.py b/tests/integration/renderer/test_render_type.py new file mode 100644 index 0000000000..17a6cfbae3 --- /dev/null +++ b/tests/integration/renderer/test_render_type.py @@ -0,0 +1,66 @@ +from dash import Dash, Input, Output, html +import json + +import dash_test_components as dt + + +def test_rtype001_rendertype(dash_duo): + app = Dash() + + app.layout = html.Div( + [ + html.Div( + dt.RenderType(id="render_test"), + id="container", + ), + html.Button("redraw", id="redraw"), + html.Button("update render", id="update_render"), + html.Button("clientside", id="clientside_render"), + html.Div(id="render_output"), + ] + ) + + app.clientside_callback( + """(n) => { + dash_clientside.set_props('render_test', {n_clicks: 20}) + }""", + Input("clientside_render", "n_clicks"), + ) + + @app.callback( + Output("container", "children"), + Input("redraw", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + return dt.RenderType(id="render_test") + + @app.callback( + Output("render_test", "n_clicks"), + Input("update_render", "n_clicks"), + prevent_initial_call=True, + ) + def update_render(_): + return 0 + + @app.callback(Output("render_output", "children"), Input("render_test", "n_clicks")) + def display_clicks(n): + return json.dumps(n) + + dash_duo.start_server(app) + + render_type = "#render_test > span" + render_output = "#render_output" + dash_duo.wait_for_text_to_equal(render_type, "parent") + dash_duo.find_element("#update_render").click() + dash_duo.wait_for_text_to_equal(render_type, "callback") + dash_duo.wait_for_text_to_equal(render_output, "0") + dash_duo.find_element("#clientside_render").click() + dash_duo.wait_for_text_to_equal(render_type, "clientsideApi") + dash_duo.wait_for_text_to_equal(render_output, "20") + dash_duo.find_element("#render_test > button").click() + dash_duo.wait_for_text_to_equal(render_type, "internal") + dash_duo.wait_for_text_to_equal(render_output, "21") + dash_duo.find_element("#redraw").click() + dash_duo.wait_for_text_to_equal(render_type, "parent") + dash_duo.wait_for_text_to_equal(render_output, "null") diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 99041a6902..c2b3b3f500 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -5,6 +5,7 @@ from selenium.webdriver.common.keys import Keys from dash import Dash, html, dcc, Input, Output, State, ALL, Patch +from dash.testing.wait import until @flaky.flaky(max_runs=3) @@ -219,52 +220,58 @@ def get_output(): _input.send_keys("Set Value") dash_duo.find_element("#set-btn").click() - assert get_output()["value"] == "Set Value" + until(lambda: get_output()["value"] == "Set Value", 2) _input = dash_duo.find_element("#append-value") _input.send_keys("Append") dash_duo.find_element("#append-btn").click() - assert get_output()["array"] == ["initial", "Append"] + until(lambda: get_output()["array"] == ["initial", "Append"], 2) _input = dash_duo.find_element("#prepend-value") _input.send_keys("Prepend") dash_duo.find_element("#prepend-btn").click() - assert get_output()["array"] == ["Prepend", "initial", "Append"] + until(lambda: get_output()["array"] == ["Prepend", "initial", "Append"], 2) _input = dash_duo.find_element("#extend-value") _input.send_keys("Extend") dash_duo.find_element("#extend-btn").click() - assert get_output()["array"] == ["Prepend", "initial", "Append", "Extend"] + until( + lambda: get_output()["array"] == ["Prepend", "initial", "Append", "Extend"], 2 + ) undef = object() - assert get_output().get("merge", undef) is undef + until(lambda: get_output().get("merge", undef) is undef, 2) _input = dash_duo.find_element("#merge-value") _input.send_keys("Merged") dash_duo.find_element("#merge-btn").click() - assert get_output()["merged"] == "Merged" + until(lambda: get_output()["merged"] == "Merged", 2) - assert get_output()["delete"] == "Delete me" + until(lambda: get_output()["delete"] == "Delete me", 2) dash_duo.find_element("#delete-btn").click() - assert get_output().get("delete", undef) is undef + until(lambda: get_output().get("delete", undef) is undef, 2) _input = dash_duo.find_element("#insert-value") _input.send_keys("Inserted") dash_duo.find_element("#insert-btn").click() - assert get_output().get("array") == [ - "Prepend", - "Inserted", - "initial", - "Append", - "Extend", - ] + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) _input.send_keys(" with negative index") _input = dash_duo.find_element("#insert-index") @@ -272,40 +279,56 @@ def get_output(): _input.send_keys("-1") dash_duo.find_element("#insert-btn").click() - assert get_output().get("array") == [ - "Prepend", - "Inserted", - "initial", - "Append", - "Inserted with negative index", - "Extend", - ] + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) dash_duo.find_element("#delete-index").click() - assert get_output().get("array") == [ - "Prepend", - "initial", - "Append", - "Extend", - ] + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) dash_duo.find_element("#reverse-btn").click() - assert get_output().get("array") == [ - "Extend", - "Append", - "initial", - "Prepend", - ] + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) dash_duo.find_element("#remove-btn").click() - assert get_output().get("array") == [ - "Extend", - "Append", - "Prepend", - ] + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) dash_duo.find_element("#clear-btn").click() - assert get_output()["array"] == [] + until(lambda: get_output()["array"] == [], 2) def test_pch002_patch_app_pmc_callbacks(dash_duo):