diff --git a/@plotly/dash-test-components/src/components/StyledComponent.js b/@plotly/dash-test-components/src/components/StyledComponent.js index f7e8703f40..f48dbf7884 100644 --- a/@plotly/dash-test-components/src/components/StyledComponent.js +++ b/@plotly/dash-test-components/src/components/StyledComponent.js @@ -15,7 +15,7 @@ StyledComponent.propTypes = { /** * The style */ - style: PropTypes.shape, + style: PropTypes.object, /** * The value to display @@ -27,4 +27,4 @@ StyledComponent.defaultProps = { value: '' }; -export default StyledComponent; \ No newline at end of file +export default StyledComponent; diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c993fdd65..1a8431843a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2735](https://github.com/plotly/dash/pull/2735) Configure CI for Python 3.8 and 3.12, drop support for Python 3.6 and Python 3.7 [#2736](https://github.com/plotly/dash/issues/2736) ## Added +- [#2762](https://github.com/plotly/dash/pull/2762) Add dynamic loading of component libraries. + - Add `dynamic_loading=True` to dash init. + - Add `preloaded_libraries=[]` to dash init, included libraries names will be loaded on the index like before. - [#2758](https://github.com/plotly/dash/pull/2758) - exposing `setProps` to `dash_clientside.clientSide_setProps` to allow for JS code to interact directly with the dash eco-system - [#2730](https://github.com/plotly/dash/pull/2721) Load script files with `.mjs` ending as js modules diff --git a/dash/dash-renderer/src/APIController.react.js b/dash/dash-renderer/src/APIController.react.js index ed443bad5c..9e90dc6268 100644 --- a/dash/dash-renderer/src/APIController.react.js +++ b/dash/dash-renderer/src/APIController.react.js @@ -1,6 +1,12 @@ import {batch, connect} from 'react-redux'; import {includes, isEmpty} from 'ramda'; -import React, {useEffect, useRef, useState, createContext} from 'react'; +import React, { + useEffect, + useRef, + useState, + createContext, + useCallback +} from 'react'; import PropTypes from 'prop-types'; import TreeContainer from './TreeContainer'; import GlobalErrorContainer from './components/error/GlobalErrorContainer.react'; @@ -21,6 +27,7 @@ import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; import {getLoadingState, getLoadingHash} from './utils/TreeContainer'; import wait from './utils/wait'; +import LibraryManager from './libraries/LibraryManager'; export const DashContext = createContext({}); @@ -46,6 +53,10 @@ const UnconnectedContainer = props => { if (!events.current) { events.current = new EventEmitter(); } + + const [libraryReady, setLibraryReady] = useState(false); + const onLibraryReady = useCallback(() => setLibraryReady(true), []); + const renderedTree = useRef(false); const propsRef = useRef({}); @@ -60,7 +71,9 @@ const UnconnectedContainer = props => { }) }); - useEffect(storeEffect.bind(null, props, events, setErrorLoading)); + useEffect( + storeEffect.bind(null, props, events, setErrorLoading, libraryReady) + ); useEffect(() => { if (renderedTree.current) { @@ -117,14 +130,23 @@ const UnconnectedContainer = props => { content =
Loading...
; } - return config && config.ui === true ? ( - {content} - ) : ( - content + return ( + + {config && config.ui === true ? ( + {content} + ) : ( + content + )} + ); }; -function storeEffect(props, events, setErrorLoading) { +function storeEffect(props, events, setErrorLoading, libraryReady) { const { appLifecycle, dependenciesRequest, @@ -143,7 +165,7 @@ function storeEffect(props, events, setErrorLoading) { } dispatch(apiThunk('_dash-layout', 'GET', 'layoutRequest')); } else if (layoutRequest.status === STATUS.OK) { - if (isEmpty(layout)) { + if (isEmpty(layout) && libraryReady) { if (typeof hooks.layout_post === 'function') { hooks.layout_post(layoutRequest.content); } @@ -186,7 +208,8 @@ function storeEffect(props, events, setErrorLoading) { layoutRequest.status === STATUS.OK && !isEmpty(layout) && // Hasn't already hydrated - appLifecycle === getAppState('STARTED') + appLifecycle === getAppState('STARTED') && + libraryReady ) { let hasError = false; try { @@ -235,7 +258,8 @@ const Container = connect( graphs: state.graphs, history: state.history, error: state.error, - config: state.config + config: state.config, + paths: state.paths }), dispatch => ({dispatch}) )(UnconnectedContainer); diff --git a/dash/dash-renderer/src/CheckedComponent.react.js b/dash/dash-renderer/src/CheckedComponent.react.js new file mode 100644 index 0000000000..4eeed6d524 --- /dev/null +++ b/dash/dash-renderer/src/CheckedComponent.react.js @@ -0,0 +1,30 @@ +import checkPropTypes from './checkPropTypes'; +import {propTypeErrorHandler} from './exceptions'; +import {createLibraryElement} from './libraries/createLibraryElement'; +import PropTypes from 'prop-types'; + +export function CheckedComponent(p) { + const {element, extraProps, props, children, type} = p; + + const errorMessage = checkPropTypes( + element.propTypes, + props, + 'component prop', + element + ); + if (errorMessage) { + propTypeErrorHandler(errorMessage, props, type); + } + + return createLibraryElement(element, props, extraProps, children); +} + +CheckedComponent.propTypes = { + children: PropTypes.any, + element: PropTypes.any, + layout: PropTypes.any, + props: PropTypes.any, + extraProps: PropTypes.any, + id: PropTypes.string, + type: PropTypes.string +}; diff --git a/dash/dash-renderer/src/TreeContainer.js b/dash/dash-renderer/src/TreeContainer.js index 304cd0be74..b79980e9a8 100644 --- a/dash/dash-renderer/src/TreeContainer.js +++ b/dash/dash-renderer/src/TreeContainer.js @@ -1,7 +1,5 @@ import React, {Component, memo, useContext} from 'react'; import PropTypes from 'prop-types'; -import Registry from './registry'; -import {propTypeErrorHandler} from './exceptions'; import { addIndex, assoc, @@ -9,25 +7,25 @@ import { concat, dissoc, equals, + has, isEmpty, isNil, - has, keys, map, mapObjIndexed, - mergeRight, + path as rpath, + pathOr, pick, pickBy, propOr, - path as rpath, - pathOr, type } from 'ramda'; +import {batch} from 'react-redux'; + import {notifyObservers, updateProps, onError} from './actions'; import isSimpleComponent from './isSimpleComponent'; import {recordUiEdit} from './persistence'; import ComponentErrorBoundary from './components/error/ComponentErrorBoundary.react'; -import checkPropTypes from './checkPropTypes'; import {getWatchedKeys, stringifyId} from './actions/dependencies'; import { getLoadingHash, @@ -35,45 +33,12 @@ import { validateComponent } from './utils/TreeContainer'; import {DashContext} from './APIController.react'; -import {batch} from 'react-redux'; +import LibraryComponent from './libraries/LibraryComponent'; const NOT_LOADING = { is_loading: false }; -function CheckedComponent(p) { - const {element, extraProps, props, children, type} = p; - - const errorMessage = checkPropTypes( - element.propTypes, - props, - 'component prop', - element - ); - if (errorMessage) { - propTypeErrorHandler(errorMessage, props, type); - } - - return createElement(element, props, extraProps, children); -} - -CheckedComponent.propTypes = { - children: PropTypes.any, - element: PropTypes.any, - layout: PropTypes.any, - props: PropTypes.any, - extraProps: PropTypes.any, - id: PropTypes.string -}; - -function createElement(element, props, extraProps, children) { - const allProps = mergeRight(props, extraProps); - if (Array.isArray(children)) { - return React.createElement(element, allProps, ...children); - } - return React.createElement(element, allProps, children); -} - function isDryComponent(obj) { return ( type(obj) === 'Object' && @@ -250,8 +215,6 @@ class BaseTreeContainer extends Component { } validateComponent(_dashprivate_layout); - const element = Registry.resolve(_dashprivate_layout); - // Hydrate components props const childrenProps = pathOr( [], @@ -455,17 +418,14 @@ class BaseTreeContainer extends Component { dispatch={_dashprivate_dispatch} error={_dashprivate_error} > - {_dashprivate_config.props_check ? ( - - ) : ( - createElement(element, props, extraProps, children) - )} + ); } diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 68320568e5..e1e4b4601b 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -34,7 +34,7 @@ import { CallbackResponseData } from '../types/callbacks'; import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies'; -import {urlBase} from './utils'; +import {crawlLayout, urlBase} from './utils'; import {getCSRFHeader} from '.'; import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; @@ -44,6 +44,9 @@ import {handlePatch, isPatch} from './patch'; import {getPath} from './paths'; import {requestDependencies} from './requestDependencies'; +import loadLibrary from '../libraries/loadLibrary'; +import fetchDist from '../libraries/fetchDist'; +import {setLibraryLoaded} from './libraries'; export const addBlockedCallbacks = createAction( CallbackActionType.AddBlocked @@ -363,6 +366,7 @@ function handleServerside( let runningOff: any; let progressDefault: any; let moreArgs = additionalArgs; + const libraries = Object.keys(getState().libraries); if (running) { sideUpdate(running.running, dispatch, paths); @@ -508,8 +512,41 @@ function handleServerside( } if (!long || data.response !== undefined) { - completeJob(); - finishLine(data); + const newLibs: string[] = []; + Object.values(data.response as any).forEach( + (newData: any) => { + Object.values(newData).forEach(newProp => { + crawlLayout(newProp, (c: any) => { + if ( + c.namespace && + !libraries.includes(c.namespace) && + !newLibs.includes(c.namespace) + ) { + newLibs.push(c.namespace); + } + }); + }); + } + ); + if (newLibs.length) { + fetchDist( + getState().config.requests_pathname_prefix, + newLibs + ) + .then(data => { + return Promise.all(data.map(loadLibrary)); + }) + .then(() => { + completeJob(); + finishLine(data); + dispatch( + setLibraryLoaded({libraries: newLibs}) + ); + }); + } else { + completeJob(); + finishLine(data); + } } else { // Poll chain. setTimeout( diff --git a/dash/dash-renderer/src/actions/libraries.ts b/dash/dash-renderer/src/actions/libraries.ts new file mode 100644 index 0000000000..5cac6b3597 --- /dev/null +++ b/dash/dash-renderer/src/actions/libraries.ts @@ -0,0 +1,10 @@ +import {LibrariesActions} from '../libraries/libraryTypes'; + +const createAction = (type: LibrariesActions) => (payload: any) => ({ + type, + payload +}); + +export const setLibraryLoading = createAction(LibrariesActions.LOAD); +export const setLibraryLoaded = createAction(LibrariesActions.LOADED); +export const setLibraryToLoad = createAction(LibrariesActions.TO_LOAD); diff --git a/dash/dash-renderer/src/libraries/LibraryComponent.tsx b/dash/dash-renderer/src/libraries/LibraryComponent.tsx new file mode 100644 index 0000000000..0b8b18104c --- /dev/null +++ b/dash/dash-renderer/src/libraries/LibraryComponent.tsx @@ -0,0 +1,47 @@ +import React, {useContext, useEffect} from 'react'; +import {LibrariesContext} from './librariesContext'; +import Registry from '../registry'; +import {CheckedComponent} from '../CheckedComponent.react'; +import {createLibraryElement} from './createLibraryElement'; + +type LibraryComponentProps = { + type: string; + namespace: string; + props: any; + extraProps: any; + children: any; + props_check: boolean; +}; + +const LibraryComponent = (props: LibraryComponentProps) => { + const {props_check, namespace, type, ...rest} = props; + + const context = useContext(LibrariesContext); + + useEffect(() => { + context.addToLoad(namespace); + }, []); + + if (!context.isLoaded(namespace)) { + return <>; + } + const element = Registry.resolve({namespace, type}); + if (props_check) { + return ( + + ); + } + return createLibraryElement( + element, + rest.props, + rest.extraProps, + rest.children + ); +}; +export default LibraryComponent; diff --git a/dash/dash-renderer/src/libraries/LibraryManager.tsx b/dash/dash-renderer/src/libraries/LibraryManager.tsx new file mode 100644 index 0000000000..ad248be751 --- /dev/null +++ b/dash/dash-renderer/src/libraries/LibraryManager.tsx @@ -0,0 +1,67 @@ +import React, {JSX, useEffect, useState} from 'react'; + +import {createLibrariesContext, LibrariesContext} from './librariesContext'; +import {crawlLayout} from '../actions/utils'; +import {isEmpty} from 'ramda'; + +type LibrariesManagerProps = { + children: JSX.Element; + requests_pathname_prefix: string; + onReady: () => void; + ready: boolean; + layout?: any; + initialLibraries?: string[]; +}; + +const LibraryProvider = (props: LibrariesManagerProps) => { + const { + children, + requests_pathname_prefix, + onReady, + ready, + initialLibraries + } = props; + const contextValue = createLibrariesContext( + requests_pathname_prefix, + initialLibraries as string[], + onReady, + ready + ); + return ( + + {children} + + ); +}; + +const LibraryManager = (props: LibrariesManagerProps) => { + const {children, ready, layout} = props; + + const [initialLibraries, setInitialLibraries] = useState( + null + ); + + useEffect(() => { + if (layout && !isEmpty(layout) && !ready && !initialLibraries) { + const libraries: string[] = []; + crawlLayout(layout, (child: any) => { + if (child.namespace && !libraries.includes(child.namespace)) { + libraries.push(child.namespace); + } + }); + setInitialLibraries(libraries); + } + }, [layout, ready, initialLibraries]); + + if (!initialLibraries) { + return children; + } + + return ( + + {children} + + ); +}; + +export default LibraryManager; diff --git a/dash/dash-renderer/src/libraries/createLibraryElement.js b/dash/dash-renderer/src/libraries/createLibraryElement.js new file mode 100644 index 0000000000..e5ebe6fa68 --- /dev/null +++ b/dash/dash-renderer/src/libraries/createLibraryElement.js @@ -0,0 +1,10 @@ +import {mergeRight} from 'ramda'; +import React from 'react'; + +export function createLibraryElement(element, props, extraProps, children) { + const allProps = mergeRight(props, extraProps); + if (Array.isArray(children)) { + return React.createElement(element, allProps, ...children); + } + return React.createElement(element, allProps, children); +} diff --git a/dash/dash-renderer/src/libraries/fetchDist.ts b/dash/dash-renderer/src/libraries/fetchDist.ts new file mode 100644 index 0000000000..d82841e6d2 --- /dev/null +++ b/dash/dash-renderer/src/libraries/fetchDist.ts @@ -0,0 +1,12 @@ +import {LibraryResource} from './libraryTypes'; + +export default function fetchDist( + pathnamePrefix: string, + libraries: string[] +): Promise { + return fetch(`${pathnamePrefix}_dash-dist`, { + body: JSON.stringify(libraries), + headers: {'Content-Type': 'application/json'}, + method: 'POST' + }).then(response => response.json()); +} diff --git a/dash/dash-renderer/src/libraries/librariesContext.ts b/dash/dash-renderer/src/libraries/librariesContext.ts new file mode 100644 index 0000000000..ed871850c8 --- /dev/null +++ b/dash/dash-renderer/src/libraries/librariesContext.ts @@ -0,0 +1,132 @@ +import {createContext, useEffect, useState} from 'react'; +import {pathOr, toPairs} from 'ramda'; +import loadLibrary from './loadLibrary'; +import {batch, useDispatch, useSelector} from 'react-redux'; +import {LibrariesState} from './libraryTypes'; +import { + setLibraryLoaded, + setLibraryLoading, + setLibraryToLoad +} from '../actions/libraries'; +import fetchDist from './fetchDist'; + +export type LibrariesContextType = { + state: LibrariesState; + isLoading: (libraryName: string) => boolean; + isLoaded: (libraryName: string) => boolean; + fetchLibraries: () => void; + getLibrariesToLoad: () => string[]; + addToLoad: (libName: string) => void; +}; + +function librarySelector(s: any) { + return s.libraries as LibrariesState; +} + +export function createLibrariesContext( + pathnamePrefix: string, + initialLibraries: string[], + onReady: () => void, + ready: boolean +): LibrariesContextType { + const dispatch = useDispatch(); + const state = useSelector(librarySelector); + const [callback, setCallback] = useState(-1); + + const isLoaded = (libraryName: string) => + pathOr(false, [libraryName, 'loaded'], state); + const isLoading = (libraryName: string) => + pathOr(false, [libraryName, 'loading'], state); + + const addToLoad = (libraryName: string) => { + const lib = state[libraryName]; + if (!lib) { + // Check if already loaded on the window + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (window[libraryName]) { + dispatch(setLibraryLoaded({libraries: [libraryName]})); + } else { + dispatch(setLibraryToLoad({library: libraryName})); + } + } + // if lib is already in don't do anything. + }; + + const getLibrariesToLoad = () => + toPairs(state).reduce((acc: string[], [key, value]) => { + if (value.toLoad) { + acc.push(key); + } + return acc; + }, []); + + const fetchLibraries = () => { + const libraries = getLibrariesToLoad(); + if (!libraries.length) { + return; + } + + dispatch(setLibraryLoading({libraries})); + + fetchDist(pathnamePrefix, libraries) + .then(data => { + return Promise.all(data.map(loadLibrary)); + }) + .then(() => { + dispatch(setLibraryLoaded({libraries})); + setCallback(-1); + onReady(); + }); + }; + + useEffect(() => { + batch(() => { + const loaded: string[] = []; + initialLibraries.forEach(lib => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (window[lib]) { + loaded.push(lib); + } else { + dispatch(setLibraryToLoad({library: lib})); + } + }); + if (loaded.length) { + dispatch(setLibraryLoaded({libraries: loaded})); + } + if (loaded.length === initialLibraries.length) { + onReady(); + } + }); + }, [initialLibraries]); + + // Load libraries on a throttle to have time to gather all the components in one go. + useEffect(() => { + if (ready) { + return; + } + const libraries = getLibrariesToLoad(); + if (!libraries.length) { + return; + } + if (callback > 0) { + window.clearTimeout(callback); + } + const timeout = window.setTimeout(fetchLibraries, 0); + setCallback(timeout); + }, [state, ready, initialLibraries]); + + return { + state, + isLoaded, + isLoading, + fetchLibraries, + getLibrariesToLoad, + addToLoad + }; +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export const LibrariesContext = createContext(null); diff --git a/dash/dash-renderer/src/libraries/libraryTypes.ts b/dash/dash-renderer/src/libraries/libraryTypes.ts new file mode 100644 index 0000000000..b1c5f4887c --- /dev/null +++ b/dash/dash-renderer/src/libraries/libraryTypes.ts @@ -0,0 +1,17 @@ +export enum LibrariesActions { + LOAD = 'LOAD_LIBRARY', + LOADED = 'LOADED_LIBRARY', + TO_LOAD = 'TO_LOAD' +} + +export type LibrariesState = { + [libname: string]: { + toLoad: boolean; + loading: boolean; + loaded: boolean; + }; +}; +export type LibraryResource = { + type: '_js_dist' | '_css_dist'; + url: string; +}; diff --git a/dash/dash-renderer/src/libraries/loadLibrary.ts b/dash/dash-renderer/src/libraries/loadLibrary.ts new file mode 100644 index 0000000000..0f83670c1d --- /dev/null +++ b/dash/dash-renderer/src/libraries/loadLibrary.ts @@ -0,0 +1,31 @@ +import {LibraryResource} from './libraryTypes'; + +export default function (resource: LibraryResource) { + let prom; + const head = document.querySelector('head'); + if (resource.type === '_js_dist') { + const element = document.createElement('script'); + element.src = resource.url; + element.async = true; + prom = new Promise((resolve, reject) => { + element.onload = () => { + resolve(); + }; + element.onerror = error => reject(error); + }); + + head?.appendChild(element); + } else if (resource.type === '_css_dist') { + const element = document.createElement('link'); + element.href = resource.url; + element.rel = 'stylesheet'; + prom = new Promise((resolve, reject) => { + element.onload = () => { + resolve(); + }; + element.onerror = error => reject(error); + }); + head?.appendChild(element); + } + return prom; +} diff --git a/dash/dash-renderer/src/reducers/libraries.ts b/dash/dash-renderer/src/reducers/libraries.ts new file mode 100644 index 0000000000..421108b486 --- /dev/null +++ b/dash/dash-renderer/src/reducers/libraries.ts @@ -0,0 +1,60 @@ +import {assocPath, pipe} from 'ramda'; +import {LibrariesActions, LibrariesState} from '../libraries/libraryTypes'; + +type LoadingPayload = { + libraries: string[]; +}; + +type LoadedPayload = { + libraries: string[]; +}; + +type ToLoadPayload = { + library: string; +}; + +type LibrariesAction = { + type: LibrariesActions; + payload: LoadingPayload | LoadedPayload | ToLoadPayload; +}; + +function handleLoad(library: string, state: LibrariesState) { + return pipe( + assocPath([library, 'loading'], true), + assocPath([library, 'toLoad'], false) + )(state) as LibrariesState; +} + +function handleLoaded(library: string, state: LibrariesState) { + return pipe( + assocPath([library, 'loaded'], true), + assocPath([library, 'loading'], false) + )(state) as LibrariesState; +} + +export default function librariesReducer( + state: LibrariesState = {}, + action: LibrariesAction +): LibrariesState { + switch (action.type) { + case LibrariesActions.LOAD: + return (action.payload as LoadingPayload).libraries.reduce( + (acc, lib) => handleLoad(lib, acc), + state + ); + case LibrariesActions.LOADED: + return (action.payload as LoadedPayload).libraries.reduce( + (acc, lib) => handleLoaded(lib, acc), + state + ); + case LibrariesActions.TO_LOAD: + return pipe( + assocPath( + [(action.payload as ToLoadPayload).library, 'toLoad'], + true + ) + )(state) as LibrariesState; + default: + return state; + } +} diff --git a/dash/dash-renderer/src/reducers/reducer.js b/dash/dash-renderer/src/reducers/reducer.js index 97b71b6bce..f025d27354 100644 --- a/dash/dash-renderer/src/reducers/reducer.js +++ b/dash/dash-renderer/src/reducers/reducer.js @@ -18,6 +18,7 @@ import layout from './layout'; import loadingMap from './loadingMap'; import paths from './paths'; import callbackJobs from './callbackJobs'; +import libraries from './libraries'; export const apiRequests = [ 'dependenciesRequest', @@ -47,6 +48,7 @@ function mainReducer() { }, apiRequests); parts.callbackJobs = callbackJobs; + parts.libraries = libraries; return combineReducers(parts); } diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index 62fcb19d20..6a499cf1d1 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -1,3 +1,5 @@ +import {LibraryResource} from '../libraries/libraryTypes'; + type CallbackId = string | {[key: string]: any}; export interface ICallbackDefinition { @@ -103,4 +105,5 @@ export type CallbackResponseData = { running?: CallbackResponse; runningOff?: CallbackResponse; cancel?: ICallbackProperty[]; + resources: LibraryResource[]; }; diff --git a/dash/dash.py b/dash/dash.py index 6de2507f9d..51aef43fcf 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -404,6 +404,8 @@ def __init__( # pylint: disable=too-many-statements add_log_handler=True, hooks: Union[RendererHooks, None] = None, routing_callback_inputs: Optional[Dict[str, Union[Input, State]]] = None, + dynamic_loading=True, + preloaded_libraries=None, **obsolete, ): _validate.check_obsolete(obsolete) @@ -458,6 +460,8 @@ def __init__( # pylint: disable=too-many-statements title=title, update_title=update_title, include_pages_meta=include_pages_meta, + dynamic_loading=dynamic_loading, + preloaded_libraries=preloaded_libraries or [], ) self.config.set_read_only( [ @@ -468,6 +472,8 @@ def __init__( # pylint: disable=too-many-statements "serve_locally", "compress", "pages_folder", + "dynamic_loading", + "preloaded_libraries", ], "Read-only: can only be set in the Dash constructor", ) @@ -645,6 +651,7 @@ def _setup_routes(self): self._add_url("_dash-update-component", self.dispatch, ["POST"]) self._add_url("_reload-hash", self.serve_reload_hash) self._add_url("_favicon.ico", self._serve_default_favicon) + self._add_url("_dash-dist", self.serve_dist, methods=["POST"]) self._add_url("", self.index) if jupyter_dash.active: @@ -798,7 +805,17 @@ def serve_reload_hash(self): } ) - def _collect_and_register_resources(self, resources): + def serve_dist(self): + libraries = flask.request.get_json() + dists = [] + for dist_type in ("_js_dist", "_css_dist"): + resources = ComponentRegistry.get_resources(dist_type, libraries) + srcs = self._collect_and_register_resources(resources, False) + for src in srcs: + dists.append(dict(type=dist_type, url=src)) + return flask.jsonify(dists) + + def _collect_and_register_resources(self, resources, include_async=True): # now needs the app context. # template in the necessary component suite JS bundles # add the version number of the package as a query parameter @@ -827,6 +844,8 @@ def _relative_url_path(relative_package_path="", namespace=""): srcs = [] for resource in resources: is_dynamic_resource = resource.get("dynamic", False) + is_async = resource.get("async") is not None + excluded = not include_async and is_async if "relative_package_path" in resource: paths = resource["relative_package_path"] @@ -838,7 +857,7 @@ def _relative_url_path(relative_package_path="", namespace=""): self.registered_paths[resource["namespace"]].add(rel_path) - if not is_dynamic_resource: + if not is_dynamic_resource and not excluded: srcs.append( _relative_url_path( relative_package_path=rel_path, @@ -846,7 +865,7 @@ def _relative_url_path(relative_package_path="", namespace=""): ) ) elif "external_url" in resource: - if not is_dynamic_resource: + if not is_dynamic_resource and not excluded: if isinstance(resource["external_url"], str): srcs.append(resource["external_url"]) else: @@ -873,7 +892,13 @@ def _relative_url_path(relative_package_path="", namespace=""): def _generate_css_dist_html(self): external_links = self.config.external_stylesheets - links = self._collect_and_register_resources(self.css.get_all_css()) + + if self.config.dynamic_loading: + links = self._collect_and_register_resources( + self.css.get_library_css(self.config.preloaded_libraries) + ) + else: + links = self._collect_and_register_resources(self.css.get_all_css()) return "\n".join( [ @@ -908,21 +933,28 @@ def _generate_scripts_html(self): self.scripts._resources._filter_resources(deps, dev_bundles=dev) ) + self.config.external_scripts - + self._collect_and_register_resources( + ) + + if not self.config.dynamic_loading: + srcs += self._collect_and_register_resources( self.scripts.get_all_scripts(dev_bundles=dev) - + self.scripts._resources._filter_resources( - _dash_renderer._js_dist, dev_bundles=dev - ) - + self.scripts._resources._filter_resources( - dcc._js_dist, dev_bundles=dev - ) - + self.scripts._resources._filter_resources( - html._js_dist, dev_bundles=dev - ) - + self.scripts._resources._filter_resources( - dash_table._js_dist, dev_bundles=dev + ) + else: + srcs += self._collect_and_register_resources( + self.scripts.get_library_scripts( + self.config.preloaded_libraries, dev_bundles=dev ) ) + + srcs += self._collect_and_register_resources( + self.scripts._resources._filter_resources( + _dash_renderer._js_dist, dev_bundles=dev + ) + + self.scripts._resources._filter_resources(dcc._js_dist, dev_bundles=dev) + + self.scripts._resources._filter_resources(html._js_dist, dev_bundles=dev) + + self.scripts._resources._filter_resources( + dash_table._js_dist, dev_bundles=dev + ) ) self._inline_scripts.extend(_callback.GLOBAL_INLINE_SCRIPTS) diff --git a/dash/development/base_component.py b/dash/development/base_component.py index b51fc06634..0bc4a28c9c 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -20,10 +20,12 @@ class ComponentRegistry: children_props = collections.defaultdict(dict) @classmethod - def get_resources(cls, resource_name): + def get_resources(cls, resource_name, includes=None): resources = [] for module_name in cls.registry: + if includes is not None and module_name not in includes: + continue module = sys.modules[module_name] resources.extend(getattr(module, resource_name, [])) diff --git a/dash/resources.py b/dash/resources.py index cae4a49470..2ac080d57a 100644 --- a/dash/resources.py +++ b/dash/resources.py @@ -88,6 +88,12 @@ def get_all_resources(self, dev_bundles=False): return self._filter_resources(all_resources, dev_bundles) + def get_library_resources(self, libraries, dev_bundles=False): + lib_resources = ComponentRegistry.get_resources(self.resource_name, libraries) + all_resources = lib_resources + self._resources + + return self._filter_resources(all_resources, dev_bundles) + # pylint: disable=too-few-public-methods class _Config: @@ -107,6 +113,9 @@ def append_css(self, stylesheet): def get_all_css(self): return self._resources.get_all_resources() + def get_library_css(self, libraries): + return self._resources.get_library_resources(libraries) + class Scripts: def __init__(self, serve_locally, eager): @@ -118,3 +127,6 @@ def append_script(self, script): def get_all_scripts(self, dev_bundles=False): return self._resources.get_all_resources(dev_bundles) + + def get_library_scripts(self, libraries, dev_bundles=False): + return self._resources.get_library_resources(libraries, dev_bundles) diff --git a/tests/integration/long_callback/app_page_cancel.py b/tests/integration/long_callback/app_page_cancel.py index 5134ad7a14..d99ed6ac82 100644 --- a/tests/integration/long_callback/app_page_cancel.py +++ b/tests/integration/long_callback/app_page_cancel.py @@ -1,7 +1,5 @@ from dash import Dash, Input, Output, dcc, html, page_container, register_page -import time - from tests.integration.long_callback.utils import get_long_callback_manager long_callback_manager = get_long_callback_manager() @@ -23,7 +21,7 @@ page_container, ] ) - +app.test_lock = lock = long_callback_manager.test_lock register_page( "one", @@ -78,7 +76,8 @@ def on_click_no_cancel(_): interval=300, ) def on_click1(n_clicks): - time.sleep(2) + with lock: + pass return f"Click {n_clicks}" @@ -97,7 +96,8 @@ def on_click1(n_clicks): interval=300, ) def on_click1(n_clicks): - time.sleep(2) + with lock: + pass return f"Click {n_clicks}" diff --git a/tests/integration/long_callback/test_basic_long_callback016.py b/tests/integration/long_callback/test_basic_long_callback016.py index 9b8f781ed2..e822a408e4 100644 --- a/tests/integration/long_callback/test_basic_long_callback016.py +++ b/tests/integration/long_callback/test_basic_long_callback016.py @@ -1,5 +1,4 @@ import sys -import time import pytest @@ -12,32 +11,37 @@ def test_lcbc016_multi_page_cancel(dash_duo, manager): with setup_long_callback_app(manager, "app_page_cancel") as app: dash_duo.start_server(app) - dash_duo.find_element("#start1").click() - dash_duo.wait_for_text_to_equal("#progress1", "running") - dash_duo.find_element("#shared_cancel").click() - dash_duo.wait_for_text_to_equal("#progress1", "idle") - time.sleep(2.1) + + with app.test_lock: + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + dash_duo.wait_for_text_to_equal("#output1", "initial") - dash_duo.find_element("#start1").click() - dash_duo.wait_for_text_to_equal("#progress1", "running") - dash_duo.find_element("#cancel1").click() - dash_duo.wait_for_text_to_equal("#progress1", "idle") - time.sleep(2.1) + with app.test_lock: + dash_duo.find_element("#start1").click() + dash_duo.wait_for_text_to_equal("#progress1", "running") + dash_duo.find_element("#cancel1").click() + dash_duo.wait_for_text_to_equal("#progress1", "idle") + dash_duo.wait_for_text_to_equal("#output1", "initial") dash_duo.server_url = dash_duo.server_url + "/2" - dash_duo.find_element("#start2").click() - dash_duo.wait_for_text_to_equal("#progress2", "running") - dash_duo.find_element("#shared_cancel").click() - dash_duo.wait_for_text_to_equal("#progress2", "idle") - time.sleep(2.1) + with app.test_lock: + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#shared_cancel").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + dash_duo.wait_for_text_to_equal("#output2", "initial") - dash_duo.find_element("#start2").click() - dash_duo.wait_for_text_to_equal("#progress2", "running") - dash_duo.find_element("#cancel2").click() - dash_duo.wait_for_text_to_equal("#progress2", "idle") - time.sleep(2.1) + with app.test_lock: + dash_duo.find_element("#start2").click() + dash_duo.wait_for_text_to_equal("#progress2", "running") + dash_duo.find_element("#cancel2").click() + dash_duo.wait_for_text_to_equal("#progress2", "idle") + dash_duo.wait_for_text_to_equal("#output2", "initial") diff --git a/tests/integration/renderer/test_libraries.py b/tests/integration/renderer/test_libraries.py new file mode 100644 index 0000000000..e7332c99d3 --- /dev/null +++ b/tests/integration/renderer/test_libraries.py @@ -0,0 +1,52 @@ +from dash import Dash, html, Input, Output +import dash_test_components as dt +import dash_generator_test_component_standard as dgs + + +def test_rblib001_dynamic_loading(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("Insert", id="insert-btn"), + html.Div(id="output"), + dgs.MyStandardComponent(id="dgs"), + ] + ) + + @app.callback( + Output("output", "children"), + [Input("insert-btn", "n_clicks")], + prevent_initial_call=True, + ) + def update_output(_): + import dash_generator_test_component_nested as dn + + return [ + dt.StyledComponent(value="Styled", id="styled"), + dn.MyNestedComponent(value="nested", id="nested"), + ] + + dash_duo.start_server(app) + + def assert_unloaded(namespace): + assert dash_duo.driver.execute_script( + f"return window['{namespace}'] === undefined" + ) + + def assert_loaded(namespace): + assert dash_duo.driver.execute_script( + f"return window['{namespace}'] !== undefined" + ) + + assert_unloaded(dt.package_name) + assert_unloaded("dash_generator_test_component_nested") + dash_duo.wait_for_element("#dgs") + assert_unloaded(dt.package_name) + assert_loaded(dgs.package_name) + + dash_duo.wait_for_element("#insert-btn").click() + + dash_duo.wait_for_element("#styled") + assert_loaded(dt.package_name) + assert_loaded("dash_generator_test_component_nested") diff --git a/tests/integration/test_generation.py b/tests/integration/test_generation.py index 1ed4f7ac78..b888c5b2cc 100644 --- a/tests/integration/test_generation.py +++ b/tests/integration/test_generation.py @@ -56,7 +56,13 @@ def test_gene001_simple_callback(dash_duo): def test_gene002_arbitrary_resources(dash_duo): app = Dash(__name__) - app.layout = Div([Button("Click", id="btn"), Div(id="container")]) + app.layout = Div( + [ + Button("Click", id="btn"), + Div(id="container"), + MyStandardComponent(), + ] + ) @app.callback(Output("container", "children"), [Input("btn", "n_clicks")]) def update_container(n_clicks):