diff --git a/@plotly/dash-test-components/src/components/ExternalComponent.js b/@plotly/dash-test-components/src/components/ExternalComponent.js
new file mode 100644
index 0000000000..bb3369d87e
--- /dev/null
+++ b/@plotly/dash-test-components/src/components/ExternalComponent.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+const ExternalComponent = ({ id, text, input_id }) => {
+ const ctx = window.dash_component_api.useDashContext();
+ const ExternalWrapper = window.dash_component_api.ExternalWrapper;
+
+ return (
+
+
+
+ )
+}
+
+ExternalComponent.propTypes = {
+ id: PropTypes.string,
+ text: PropTypes.string,
+ input_id: PropTypes.string,
+};
+
+export default ExternalComponent;
diff --git a/@plotly/dash-test-components/src/index.js b/@plotly/dash-test-components/src/index.js
index bdddfc18b5..f72bfd0521 100644
--- a/@plotly/dash-test-components/src/index.js
+++ b/@plotly/dash-test-components/src/index.js
@@ -13,6 +13,7 @@ import AddPropsComponent from "./components/AddPropsComponent";
import ReceivePropsComponent from "./components/ReceivePropsComponent";
import ShapeOrExactKeepOrderComponent from "./components/ShapeOrExactKeepOrderComponent";
import ArrayOfExactOrShapeWithNodePropAssignNone from './components/ArrayOfExactOrShapeWithNodePropAssignNone';
+import ExternalComponent from './components/ExternalComponent';
export {
@@ -29,5 +30,6 @@ export {
AddPropsComponent,
ReceivePropsComponent,
ShapeOrExactKeepOrderComponent,
- ArrayOfExactOrShapeWithNodePropAssignNone
+ ArrayOfExactOrShapeWithNodePropAssignNone,
+ ExternalComponent,
};
diff --git a/dash/dash-renderer/src/actions/constants.js b/dash/dash-renderer/src/actions/constants.js
index 352f25be88..9c744f1655 100644
--- a/dash/dash-renderer/src/actions/constants.js
+++ b/dash/dash-renderer/src/actions/constants.js
@@ -8,7 +8,9 @@ const actionList = {
SET_CONFIG: 1,
ADD_HTTP_HEADERS: 1,
ON_ERROR: 1,
- SET_HOOKS: 1
+ SET_HOOKS: 1,
+ INSERT_COMPONENT: 1,
+ REMOVE_COMPONENT: 1
};
export const getAction = action => {
diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js
index 2b1dd51324..e46644ff9d 100644
--- a/dash/dash-renderer/src/actions/index.js
+++ b/dash/dash-renderer/src/actions/index.js
@@ -18,6 +18,8 @@ export const setLayout = createAction(getAction('SET_LAYOUT'));
export const setPaths = createAction(getAction('SET_PATHS'));
export const setRequestQueue = createAction(getAction('SET_REQUEST_QUEUE'));
export const updateProps = createAction(getAction('ON_PROP_CHANGE'));
+export const insertComponent = createAction(getAction('INSERT_COMPONENT'));
+export const removeComponent = createAction(getAction('REMOVE_COMPONENT'));
export const dispatchError = dispatch => (message, lines) =>
dispatch(
diff --git a/dash/dash-renderer/src/dashApi.ts b/dash/dash-renderer/src/dashApi.ts
index f388299d85..a0a914c5e4 100644
--- a/dash/dash-renderer/src/dashApi.ts
+++ b/dash/dash-renderer/src/dashApi.ts
@@ -2,6 +2,7 @@ import {path} from 'ramda';
import {DashContext, useDashContext} from './wrapper/DashContext';
import {getPath} from './actions/paths';
import {getStores} from './utils/stores';
+import ExternalWrapper from './wrapper/ExternalWrapper';
/**
* Get the dash props from a component path or id.
@@ -28,6 +29,7 @@ function getLayout(componentPathOrId: string[] | string): any {
}
(window as any).dash_component_api = {
+ ExternalWrapper,
DashContext,
useDashContext,
getLayout
diff --git a/dash/dash-renderer/src/reducers/layout.js b/dash/dash-renderer/src/reducers/layout.js
index b0986aec07..e9c2aba0e7 100644
--- a/dash/dash-renderer/src/reducers/layout.js
+++ b/dash/dash-renderer/src/reducers/layout.js
@@ -1,4 +1,12 @@
-import {includes, mergeRight, append, view, lensPath, assocPath} from 'ramda';
+import {
+ includes,
+ mergeRight,
+ append,
+ view,
+ lensPath,
+ assocPath,
+ dissocPath
+} from 'ramda';
import {getAction} from '../actions/constants';
@@ -20,6 +28,14 @@ const layout = (state = {}, action) => {
const mergedProps = mergeRight(existingProps, action.payload.props);
return assocPath(propPath, mergedProps, state);
}
+ // Custom component rendered out of tree.
+ else if (action.type === getAction('INSERT_COMPONENT')) {
+ const {component, componentPath} = action.payload;
+ return assocPath(componentPath, component, state);
+ } else if (action.type === getAction('REMOVE_COMPONENT')) {
+ const {componentPath} = action.payload;
+ return dissocPath(componentPath, state);
+ }
return state;
};
diff --git a/dash/dash-renderer/src/wrapper/DashContext.tsx b/dash/dash-renderer/src/wrapper/DashContext.tsx
index 249da52732..1da719f664 100644
--- a/dash/dash-renderer/src/wrapper/DashContext.tsx
+++ b/dash/dash-renderer/src/wrapper/DashContext.tsx
@@ -9,15 +9,19 @@ type LoadingFilterFunc = (loading: LoadingPayload) => boolean;
type LoadingOptions = {
/**
- *
+ * Path to add after the current component if loading.
+ * Ex `["props"]` will return true only for when that component load.
*/
extraPath?: DashLayoutPath;
/**
- *
+ * A raw path used instead of the current component.
+ * Useful if you want the loading of a child component
+ * as the path is available in `child.props.componentPath`.
*/
rawPath?: boolean;
/**
* Function used to filter the properties of the loading component.
+ * Filter argument is an Entry of `{path, property, id}`.
*/
filterFunc?: LoadingFilterFunc;
};
@@ -113,5 +117,12 @@ export function DashContextProvider(props: DashContextProviderProps) {
}
export function useDashContext() {
- return useContext(DashContext);
+ const ctx = useContext(DashContext);
+ if (!ctx) {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'Dash Context was not found, component was rendered without a wrapper. Use `window.dash_component_api.ExternalWrapper` to make sure the component is properly connected.'
+ );
+ }
+ return ctx || {};
}
diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx
index b9839a9472..2756ff54a3 100644
--- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx
+++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx
@@ -422,10 +422,24 @@ function DashWrapper({
);
}
-export default memo(
- DashWrapper,
- (prevProps, nextProps) =>
- JSON.stringify(prevProps.componentPath) ===
- JSON.stringify(nextProps.componentPath) &&
- prevProps._dashprivate_error === nextProps._dashprivate_error
-);
+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);
diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx
new file mode 100644
index 0000000000..025bb2ea55
--- /dev/null
+++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx
@@ -0,0 +1,52 @@
+import React, {useState, useEffect} from 'react';
+import {useDispatch} from 'react-redux';
+
+import {DashLayoutPath} from '../types/component';
+import DashWrapper from './DashWrapper';
+import {insertComponent, removeComponent} from '../actions';
+
+type Props = {
+ componentPath: DashLayoutPath;
+ componentType: string;
+ componentNamespace: string;
+ [k: string]: any;
+};
+
+/**
+ * For rendering components that are out of the regular layout tree.
+ */
+function ExternalWrapper({
+ componentType,
+ componentNamespace,
+ componentPath,
+ ...props
+}: Props) {
+ const dispatch = useDispatch();
+ const [inserted, setInserted] = useState(false);
+
+ useEffect(() => {
+ // Give empty props for the inserted components.
+ // The props will come from the parent so they can be updated.
+ dispatch(
+ insertComponent({
+ component: {
+ type: componentType,
+ namespace: componentNamespace,
+ props: {}
+ },
+ componentPath
+ })
+ );
+ setInserted(true);
+ return () => {
+ dispatch(removeComponent({componentPath}));
+ };
+ }, []);
+
+ if (!inserted) {
+ return null;
+ }
+ // Render a wrapper with the actual props.
+ return ;
+}
+export default ExternalWrapper;
diff --git a/tests/integration/renderer/test_external_component.py b/tests/integration/renderer/test_external_component.py
new file mode 100644
index 0000000000..db095214a2
--- /dev/null
+++ b/tests/integration/renderer/test_external_component.py
@@ -0,0 +1,27 @@
+from dash import Dash, html, dcc, html, Input, Output, State
+from dash_test_components import ExternalComponent
+
+
+def test_rext001_render_external_component(dash_duo):
+ app = Dash()
+ app.layout = html.Div(
+ [
+ dcc.Input(id="sync", value="synced"),
+ html.Button("sync", id="sync-btn"),
+ ExternalComponent("ext", input_id="external", text="external"),
+ ]
+ )
+
+ @app.callback(
+ Output("ext", "text"),
+ Input("sync-btn", "n_clicks"),
+ State("sync", "value"),
+ prevent_initial_call=True,
+ )
+ def on_sync(_, value):
+ return value
+
+ dash_duo.start_server(app)
+ dash_duo.wait_for_text_to_equal("#external", "external")
+ dash_duo.find_element("#sync-btn").click()
+ dash_duo.wait_for_text_to_equal("#external", "synced")