Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance of context components re-rendering #3066

Open
wants to merge 11 commits into
base: dev
Choose a base branch
from

Conversation

T4rk1n
Copy link
Contributor

@T4rk1n T4rk1n commented Nov 7, 2024

Fix #3057

  • Refactor setProps reducer to partially updates the layout instead of returning a new layout object.
  • New rendering component, DashWrapper replace TreeContainer.
  • Remove DashContext, the state is now handled in react-redux selectors.

Gif showing only the clicked button is re-rendered:
single-update

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Nov 7, 2024

The test_redraw shows failure with 5 redraw instead of 2.

There is two cases of additional redraw that need to be fixed in this pr:

  • the loading_state selector get triggered. Adding +2 redraw.
  • While the props is the same when returning, the equality check fails for props since it's a new object. Adding +1 redraw.
    • Checking all the props all the time is too expensive.
    • Shallow check will fail for objects that might be simple or complex.

{Array.isArray(layout) ? (
layout.map((c, i) =>
isSimpleComponent(c) ? (
c
) : (
<TreeContainer
<DashWrapper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, new component - is this going to break any legacy code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it shouldn't as this is an internal component.

@@ -1,4 +1,4 @@
type Config = {
export type DashConfig = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 for the name change - why does it have to be exported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is used in the new DashWrapper.tsx.

@@ -26,6 +26,18 @@ export const apiRequests = [
'loginRequest'
];

function callbackNum(state = 0, action) {
// With the refactor of TreeContainer to DashWrapper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for the explanation

@@ -0,0 +1,425 @@
import React, {useMemo, useCallback} from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'm going to trust you on this - I don't know enough of Dash and TypeScript to review something this large. @emilykl can you please have a look? (who else might be a good reviewer?)

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Nov 11, 2024

I think for the additional redraw might be coming from the new path, before it was JSON.stringify(path) and had a memo on that. Think it might need to be back like that or with an additional equalityFn on the memo for the path where it does JSON.stringify to compare the paths.

@NOTMEE12
Copy link

Hi. Are there any updates on this?

@AnnMarieW
Copy link
Collaborator

AnnMarieW commented Dec 9, 2024

@T4rk1n

I ran the dmc-docs using this branch, and it's much faster! Thanks so much for doing this PR. This will make large apps using DMC perform much better.

On the dev branch this app works fine, but on this branch, a component sent to the icon prop throws errors.

import dash_mantine_components as dmc
from dash import Dash, _dash_renderer
_dash_renderer._set_react_version("18.2.0")
from dash_iconify import DashIconify
app = Dash(external_stylesheets=dmc.styles.ALL)



app.layout = dmc.MantineProvider(
    dmc.Checkbox(
            label="Custom checked icon",
            checked=True,
            icon=DashIconify(icon="ion:bag-check-sharp"),
            size="lg",
        )
)

if __name__ == "__main__":
    app.run(debug=True)

Here's one of the error messages (There are a few more as well)

Uncaught TypeError: Cannot read properties of undefined (reading 'props')
    at n.47474.s.default.createElement.r.icon (dash_mantine_components.v0_15_1m1733503561.js:2:1076461)
    at renderWithHooks ([email protected]_18_2m1733760928.2.0.js:16315:20)
    at mountIndeterminateComponent ([email protected]_18_2m1733760928.2.0.js:20084:15)
    at beginWork ([email protected]_18_2m1733760928.2.0.js:21597:18)
    at HTMLUnknownElement.callCallback ([email protected]_18_2m1733760928.2.0.js:4151:16)
    at Object.invokeGuardedCallbackDev ([email protected]_18_2m1733760928.2.0.js:4200:18)
    at invokeGuardedCallback ([email protected]_18_2m1733760928.2.0.js:4264:33)
    at beginWork$1 ([email protected]_18_2m1733760928.2.0.js:27461:9)
    at performUnitOfWork ([email protected]_18_2m1733760928.2.0.js:26567:14)
    at workLoopSync ([email protected]_18_2m1733760928.2.0.js:26476:7)

Update

It's not just the DashIconify library. It's not possible to pass any components to the icon prop:

import dash_mantine_components as dmc
from dash import Dash, _dash_renderer, html
_dash_renderer._set_react_version("18.2.0")

FONT_AWESOME = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"

app = Dash(external_stylesheets=[FONT_AWESOME])

app.layout = dmc.MantineProvider([
    html.I(className="fa-solid fa-bag-shopping fa-3x"),
    dmc.Checkbox(
            label="Custom checked icon",
            checked=True,
            icon=html.I(className="fa-solid fa-bag-shopping fa-3x"),
            size="lg",
        )
])


if __name__ == "__main__":
    app.run(debug=True)

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 9, 2024

@AnnMarieW I removed the _dashprivate_layout from the given props, it's a "private" props and shouldn't have been used outside of the renderer.

https://github.com/snehilvj/dash-mantine-components/blob/529231820bc2ca25db396cb35d637618b6df62cc/src/ts/components/core/checkbox/Checkbox.tsx#L66

@AnnMarieW
Copy link
Collaborator

AnnMarieW commented Dec 9, 2024

@T4rk1n Thanks for your speedy response. I updated to use React.cloneElement instead and it works fine 🎉

Update: Actually it doesn't work 😢 It doesn't throw errors, but it doesn't render correctly 🤔

@alexcjohnson
Copy link
Collaborator

There's one other user of _dashprivate_layout in this repo (dcc.ConfirmDialogProvider), and it's also used by both DDK and DBE https://github.com/search?q=org%3Aplotly+_dashprivate_layout&type=code, in addition to DBC and as @AnnMarieW points out DMC. @T4rk1n is removing this necessary to the performance improvements? If not, it seems like we really shouldn't tamper with it. But if it is necessary, we'll need to come up with an alternate pattern that will work for our own uses of it, and then give these other libraries time and assistance to adopt the pattern as well.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 10, 2024

I removed the _dashprivate props because it was not necessary anymore in the wrapper props, still think it was a mistake to include them in given props with a name like that when it should be part of the API. While the performance impact isn't that much to add it back, it does contains the whole layout starting from the component, duplicated for each components the memory footprint is non-negligible for just a few usecases.

The proper way to handle this kind of pattern (send data up/down stream the component tree) in react is with context components, which this PR fixes their performance.

@AnnMarieW
Copy link
Collaborator

@T4rk1n

Thanks for the suggestion of using context instead. We are using the dashprivate props in multiple components in DMC, so this will take a while to update.

You make a good point here:

contains the whole layout starting from the component, duplicated for each components the memory footprint is non-negligible for just a few usecases.

Given that this PR substantially increases performance, would you consider keeping the dashprivate props for now, and removing in a future update?

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 10, 2024

Given that this PR substantially increases performance, would you consider keeping the dashprivate props for now, and removing in a future update?

Yes, I'll put it back since it's used everywhere. There could be an alternative get_props on the clientside that would return the raw props in a future update before deprecating and removing this dashprivate api.

@AnnMarieW
Copy link
Collaborator

This would be awesome:

There could be an alternative get_props on the clientside that would return the raw props in a future update before deprecating and removing this dashprivate api.

❤️ ❤️

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 11, 2024

Not sure I can add back those props in the way it was, there was another component in between the TreeContainer and the library component that is not longer the case. Might need to refactor the new Wrapper to have a middle component but that changes the order of renders and is not as optimal as a single component since it add overhead.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 11, 2024

And I think now with this to access the child props the new path would be just without a level and the _dashprivate_layout.props.
So instead of child.props._dashprivate_layout.props you would use child.props directly.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 11, 2024

And I think now with this to access the child props the new path would be just without a level and the _dashprivate_layout.props. So instead of child.props._dashprivate_layout.props you would use child.props directly.

Actually, the props are not there anymore in the wrapper, they are now gotten from the selector, so the old hack cannot work anymore with this solution.

@AnnMarieW
Copy link
Collaborator

so the old hack cannot work anymore with this solution.

Is there a "new hack" that might be easier than using context?

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 11, 2024

so the old hack cannot work anymore with this solution.

Is there a "new hack" that might be easier than using context?

A Context solution might only be valid in some cases, for the mantine checkbox case, it want to add extra props to the component.

I see a couple solution for those cases:

  1. Could add extraProps to DashWrapper props that would be merged with the component props from the store, this would make React.cloneElement(child, {extraProps: {selected: true}}) to work.
  2. change the clientside set_props to accept a path instead of an id. Then you can use children[0]._dashprivate_path to add new props to the components.

For accessing the props (dcc.Tabs cases), a new get_props(path) could be added or refactor the components to use a context.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 12, 2024

@AnnMarieW I added all three solutions from the last post.

  1. Added extras prop to DashWrapper component, think this one could replace dmc checkbox:
const iconFunc = ({ indeterminate, ...others }) => {
    const selected: any = indeterminate ? indeterminateIcon : icon;
    return React.cloneElement(selected, {extras: others});
};
  1. set_props can take a dash path instead of id.
  2. Added get_props(pathOrId), to get the props of a component. The path is available in children[0].props.componentPath.

Let me know if that work.

@AnnMarieW
Copy link
Collaborator

Thanks for the update @T4rk1n I'll try to figure out how to use these new features.

Will it handle this pattern too? https://github.com/snehilvj/dash-mantine-components/blob/83a1cc12e5e6b210b7a1e27c7a83068abe2830c5/src/ts/components/core/timeline/Timeline.tsx#L46

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 12, 2024

Thanks for the update @T4rk1n I'll try to figure out how to use these new features.

Will it handle this pattern too? https://github.com/snehilvj/dash-mantine-components/blob/83a1cc12e5e6b210b7a1e27c7a83068abe2830c5/src/ts/components/core/timeline/Timeline.tsx#L46

For that one can use window.dash_clientside.get_props(child.props.componentProps)

@AnnMarieW
Copy link
Collaborator

AnnMarieW commented Dec 13, 2024

@T4rk1n

Do you know how to get the component type as used here in in this Stepper component

 const childType = child.props._dashprivate_layout.type;
 if (childType === "StepperCompleted") {
      ....
                   

@BSd3v
Copy link
Contributor

BSd3v commented Dec 13, 2024

@T4rk1n,

Hmm... would it be possible to change it from get_props to get_attributes? Where it could return type, namespace, props and if you wanted to, you could target props just like how you are targeting the value in the one component?

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 13, 2024

I changed get_props to get_layout to match the previous _dashprivate_layout it returns type, namespace, props. Can also concat the path to get props values, eg: [...child.props.componentPath, 'props', 'value']

@AnnMarieW
Copy link
Collaborator

@T4rk1n

It looks like dcc.Loading is not working with this update .

Here's the first example from the docs - loading spinners don't show up

from dash import Dash, dcc, html, Input, Output, callback
import time

app = Dash()

app.layout = html.Div([
    html.H3("Edit text input to see loading state"),
    html.Div("Input triggers local spinner"),
    dcc.Input(id="loading-input-1"),
    dcc.Loading(
        id="loading-1",
        type="default",
        children=html.Div(id="loading-output-1")
    ),
    html.Div([
        html.Div('Input triggers nested spinner'),
        dcc.Input(id="loading-input-2"),
        dcc.Loading(
            id="loading-2",
            children=[html.Div([html.Div(id="loading-output-2")])],
            type="circle",
        )
    ]),
])


@callback(Output("loading-output-1", "children"), Input("loading-input-1", "value"))
def input_triggers_spinner(value):
    time.sleep(1)
    return value


@callback(Output("loading-output-2", "children"), Input("loading-input-2", "value"))
def input_triggers_nested(value):
    time.sleep(1)
    return value


if __name__ == "__main__":
    app.run(debug=False)

@emilhe
Copy link
Contributor

emilhe commented Dec 16, 2024

@T4rk1n In some cases, it is desireable (necessary?) to render other Dash components beyond what is passed to the children property. A example is icon propety in dmc, where a component is to be rendered (including callback bindings) similar to the children property. As I see it, we need two pieces of functionality,

  1. To be able to lookup a component type
  2. To be able to render a component (given type and props), including callback bindings

It seems that the proposed get_layout function satisfies (1). I wrote renderDashComponents to provide (2), but I am not sure if there are any other utility functions available providing this functionality?

As a last resort, I guess I could re-write the renderDashComponents function in dash-extensions-js to adopt the new syntax, but I would of course prefer an official, non-private API based solution if possible :)

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 16, 2024

It looks like dcc.Loading is not working with this update .

Yes, the loading state stuff incurred two extra renders so I had merged the loading_state selector with the props but the equality function still need to be adapted or something, working on that.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 16, 2024

To be able to render a component (given type and props), including callback bindings

I don't think this is a good pattern, the components are already hydrated, they only need to be mounted for the rendering. Can now add props set_props(pathOrId, payload) or React.cloneElement(dashComponent, {extras: {style: {background: 'red'}}). The path would now be available at component.props.componentPath.

The renderDashComponent does not return a Wrapper or previously TreeContainer and may be disconnected from the store.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 16, 2024

I guess we could add a window.dash_clientside.render(path: DashPath, component: DashComponent): DashWrapper to be able to add children from components. But it shouldn't be used to replace the already rendered components that comes in the props.

I guess this is a limitation of the layout stuff returning raw components, I'll see if it can return hydrated component instead.

@AnnMarieW
Copy link
Collaborator

@T4rk1n

In DMC PR # 458 I've used the new features you added to eliminate all the _dashprivate_layout from DMC. Works great - Thanks! 🎉

DMC is using Emil's renderDashComponets function in these 4 components in case you wanted to see a use-case.

Is it OK to continue to use _dashprivate_pushstate ? We use it in dmc.Anchor so it works like dcc.Link. It's also used in these components in the Dash Bootstrap Components library.

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 16, 2024

Is it OK to continue to use _dashprivate_pushstate ?

@AnnMarieW Yea that is just for the location component, it shouldn't have been prefixed with _dashprivate and a better name but it's unlikely to change for now.

@AnnMarieW
Copy link
Collaborator

@T4rk1n
Does this mean that every component that adds data-dash-is-loading needs to be updated too?
https://github.com/plotly/dash/blob/dev/components/dash-core-components/src/fragments/Graph.react.js#L523

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 19, 2024

@T4rk1n Does this mean that every component that adds data-dash-is-loading needs to be updated too? https://github.com/plotly/dash/blob/dev/components/dash-core-components/src/fragments/Graph.react.js#L523

Yes, if it's really needed, there will be ctx.isLoading and ctx.useLoading() as replacement.

@AnnMarieW
Copy link
Collaborator

Yes, if it's really needed, there will be ctx.isLoading and ctx.useLoading() as replacement.

Could you show an example of how to update the dcc.Graph component? It would be good to still enabling styling a component that's loading via CSS as described on at the end of this page: https://dash.plotly.com/loading-states

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 20, 2024

@AnnMarieW For class component we can replace the div with data-dash-is-loading with a component like this one:

import React from 'react';
import PropTypes from 'prop-types';

export default function LoadingDiv({children, loading_state, ...props}) {
    let loading;
    if (window.dash_component_api) {
        const ctx = window.dash_component_api.useDashContext();
        loading = ctx.useLoading();
    } else {
        loading = loading_state?.is_loading;
    }
    
    return (
        <div {...props} data-dash-is-loading={loading || undefined}>
            {children}
        </div>
    );
}

This would also be backward compatible with the old loading_state.

@AnnMarieW
Copy link
Collaborator

Hi @T4rk1n

I see the new LoadingElement - that looks great! Will it work with elements like a Mantine component rather than a standard jsx component? It causes some problems if a Mantine component is wrapped in an extra div as described in issue 463

@T4rk1n
Copy link
Contributor Author

T4rk1n commented Dec 20, 2024

Hi @T4rk1n

I see the new LoadingElement - that looks great! Will it work with elements like a Mantine component rather than a standard jsx component? It causes some problems if a Mantine component is wrapped in an extra div as described in issue 463

If the component accept data-dash-is-loading and pass it to it's dom node can use const loading = ctx.useLoading() in function components.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature something new performance something is slow
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Serious performance issues related to React context
7 participants