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

dcc.Location refresh=False doesn't fire the callback for derived URL properties #925

Open
chriddyp opened this issue Feb 4, 2021 · 5 comments

Comments

@chriddyp
Copy link
Member

chriddyp commented Feb 4, 2021

Consider a set of components that update the URL, like a dropdown allowing the user to change websites or radio items allowing users to change units from metric to imperial.

With dcc.Link, you can update the URL without refreshing the page and the rest of the content in the page can update dynamically by listening to dcc.Location properties.

However, if these components are dropdowns or radio items (or tabs) that update dcc.Location directly then there isn't a way to not refresh the page and update the content. dcc.Location(refresh=False) sort of does what we want in that it updates the URL without refreshing the page but it doesn't fire the callback, making dcc.Location unable to update the content.

Actually this isn't quite right, see comment below.

Here is an example:

# This app demonstrates how URLs can be updated via Dropdowns and RadioItems
# and content on pages can be read.
import dash
from dash.dependencies import Input, Output, State
import dash_html_components as html
import dash_core_components as dcc
import urllib

app = dash.Dash(__name__, suppress_callback_exceptions=True)

app.layout = html.Div([


    dcc.Location(id='url', refresh=True),
    html.Div(id='layout-div'),
    
    html.H1('Inner Content'),
    html.Div('''
        This content should change when navigating the pages via the
        dcc.Link or the radio items & dropdowns.
    '''),
    html.Div(id='content', style={
        'border': 'thin hotpink solid',
        'padding': 10,
        'margin': 10
    }),
    
    html.Hr(),
    
    html.H1('Preset A Links'),
    html.Div('''
        These html.A (<a href="..."/>) links should force the page to reload
        no matter what.
    '''),    
    html.Div(html.A('Site 1 in Imperial', href='/site-1?unit=Imperial')),
    html.Div(html.A('Site 3 in Metric', href='/site-1?unit=Metric')),
    html.Div(html.A('Site 2 with no units in URL', href='/site-2')),

    html.H1('Preset dcc.Link Links'),
    html.Div('''
        These dcc.Link (<a href="..."/>) links should update the inner content
        without refreshing the page
    '''),    
    html.Div(dcc.Link('Site 1 in Imperial', href='/site-1?unit=Imperial')),
    html.Div(dcc.Link('Site 3 in Metric', href='/site-1?unit=Metric')),
    html.Div(dcc.Link('Site 2 with no units in URL', href='/site-2')),


])


# the `no_update` below will prevent the callback chain from executing,
# including this callback. So, we have to target different parts of the URL
@app.callback(Output('content', 'children'), Input('url', 'href'))
def display_page(href):
    o = list(urllib.parse.urlparse(href))
    q = dict(urllib.parse.parse_qsl(o[4]))
    pathname = o[2]
    return html.Div([
        dcc.RadioItems(
            id='unit-input',
            value=q.get('unit', 'Imperial'),
            options=[{'label': i, 'value': i} for i in ['Imperial', 'Metric']]
        ),
        dcc.Dropdown(
            id='site-input',
            value=o[2][1:] or 'site-0',
            options=[
                {'label': f'site-{i}', 'value': f'site-{i}'}
                for i in range(5)
            ]
        ),
        html.Div(id='dummy'),
        html.Div(id='output')
    ])  


@app.callback(
    Output('url', 'pathname'),
    Input('site-input', 'value'),
    Input('dummy', 'children'))
def update_pathname(value, dummy):
    if len(dash.callback_context.triggered) == 2:
        # site-input rendered, it didn't change
        return dash.no_update
    return f'/{value}'


@app.callback(
    Output('url', 'search'),
    Input('unit-input', 'value'),
    Input('dummy', 'children'),
    State('url', 'search'))
def update_search_bar(value, dummy, search):
    if len(dash.callback_context.triggered) == 2:
        # unit-input rendered, it didn't change
        return dash.no_update
    if search is None:
        return '?unit=Imperial'
    q = dict(urllib.parse.parse_qsl(search[1:]))  # [1:] to remove the leading `?`
    q['unit'] = value
    query_string = urllib.parse.urlencode(q)
    return '?' + query_string


@app.callback(
    Output('output', 'children'),
    Input('url', 'href'),
    State('url', 'pathname'),
    State('url', 'search'))
def update_output_based_off_url(href, pathname, search):
    print('\n\n')
    print(href)
    print(pathname)
    print(search)
    return f'You\'ve entered {pathname}, {search}'


if __name__ == '__main__':
    app.run_server(debug=True)

With refresh=True (the default) - The page gets reloaded instead of just the content. This makes sense given the prop name:
refresh=true

With refresh=False - The URL updates (good) but the callback doesn't fire and so the content doesn't update.
page-content


Proposed Fix

Option 1 - Update the logic with refresh=False to actually fire the callback. This could break certain apps out there that are depending on the current behavior but it's easier to understand and more useful in the long run.
Option 2 - Introduce a new prop like fire_callback which would only be used if refresh=False

I prefer Option 1, but I might be overlooking real, valid usecases for this option.

@chriddyp
Copy link
Member Author

chriddyp commented Feb 4, 2021

Note that it looks like my original implemention of dcc.Location in 2017 fired the callback if refresh=False: a546a4d#diff-5f4ab044e4dd51d65b9f76a3a28e066c4b6e5cbe1f874b9b1294814085e6bd05R26

I'll dig into the commit history and see if there was a valid reason that this changed.

@chriddyp
Copy link
Member Author

chriddyp commented Feb 4, 2021

However, if these components are dropdowns or radio items (or tabs) that update dcc.Location directly then there isn't a way to not refresh the page and update the content. dcc.Location(refresh=False) sort of does what we want in that it updates the URL without refreshing the page but it doesn't fire the callback, making dcc.Location unable to update the content.

OK this wasn't quite right actually. I believe what's happening is that the callback is fired if the exact URL property was updated, but not if a derived URL property was updated.

So, if you update search as an Output but listen to href as an Input, href won't get fired when search changes.

@chriddyp chriddyp changed the title dcc.Location refresh=False doesn't fire the callback dcc.Location refresh=False doesn't fire the callback for derived URL properties Feb 4, 2021
@chriddyp
Copy link
Member Author

chriddyp commented Feb 4, 2021

So, if you update search as an Output but listen to href as an Input, href won't get fired when search changes.

As a workaround, users might be able to restructure their callbacks to target the exact same properties as inputs and outputs. I recall that there was an issue doing this (circular callbacks?) with the example above.... I'll try to reproduce now.

@chriddyp
Copy link
Member Author

chriddyp commented Feb 4, 2021

OK, here's an example that demonstrates the current behavior well. The radio items & dropdowns update href and a separate callback is triggered when href changes. href is indeed correct, however search & pathname are outdated because these types of "derived" url properties aren't changed on update.

derived-properties

# This app demonstrates how URLs can be updated via Dropdowns and RadioItems
# and content on pages can be read.
import dash
from dash.dependencies import Input, Output, State
import dash_html_components as html
import dash_core_components as dcc
import urllib

app = dash.Dash(__name__, suppress_callback_exceptions=True)

app.layout = html.Div([

    dcc.Location(id='url', refresh=False),
    html.Div(id='layout-div'),
    
    html.H1('Inner Content'),
    html.Div('''
        This content should change when navigating the pages via the
        dcc.Link or the radio items & dropdowns.
    '''),
    html.Div(id='content', style={
        'border': 'thin hotpink solid',
        'padding': 10,
        'margin': 10
    }),
    
    html.Hr(),
    
    html.H1('Preset A Links'),
    html.Div('''
        These html.A (<a href="..."/>) links should force the page to reload
        no matter what.
    '''),    
    html.Div(html.A('Site 1 in Imperial', href='/site-1?unit=Imperial')),
    html.Div(html.A('Site 3 in Metric', href='/site-1?unit=Metric')),
    html.Div(html.A('Site 2 with no units in URL', href='/site-2')),

    html.H1('Preset dcc.Link Links'),
    html.Div('''
        These dcc.Link (<a href="..."/>) links should update the inner content
        without refreshing the page
    '''),    
    html.Div(dcc.Link('Site 1 in Imperial', href='/site-1?unit=Imperial')),
    html.Div(dcc.Link('Site 3 in Metric', href='/site-1?unit=Metric')),
    html.Div(dcc.Link('Site 2 with no units in URL', href='/site-2')),

])


# the `no_update` below will prevent the callback chain from executing,
# including this callback. So, we have to target different parts of the URL
@app.callback(Output('content', 'children'), Input('url', 'href'))
def display_page(href):
    o = list(urllib.parse.urlparse(href))
    q = dict(urllib.parse.parse_qsl(o[4]))
    pathname = o[2]
    return html.Div([
        dcc.RadioItems(
            id='unit-input',
            value=q.get('unit', 'Imperial'),
            options=[{'label': i, 'value': i} for i in ['Imperial', 'Metric']]
        ),
        dcc.Dropdown(
            id='site-input',
            value=o[2][1:] or 'site-0',
            options=[
                {'label': f'site-{i}', 'value': f'site-{i}'}
                for i in range(5)
            ]
        ),
        html.Div(id='dummy'),
        html.Div(id='output')
    ])  


@app.callback(
    Output('url', 'href'),
    Input('site-input', 'value'),
    Input('unit-input', 'value'),
    Input('dummy', 'children'),
    State('url', 'href'))
def update_pathname(site, unit, dummy, href):
    if len(dash.callback_context.triggered) == 2:
        # site-input rendered, it didn't change
        return dash.no_update

    o = urllib.parse.urlparse(href)
    o = o._replace(path=f'/{site}')

    q = dict(urllib.parse.parse_qsl(o.query))
    q['unit'] = unit
    query_string = urllib.parse.urlencode(q)
    o = o._replace(query=query_string)

    return o.geturl()

@app.callback(
    Output('output', 'children'),
    Input('url', 'href'),
    State('url', 'pathname'),
    State('url', 'search'))
def update_output_based_off_url(href, pathname, search):
    return html.Div([
        html.B('Callback inputs tell me that:'),
        html.Pre(f'href={href}\npathname={pathname}\nsearch={search}')
    ])


if __name__ == '__main__':
    app.run_server(debug=True)

The good news is that this use case works if you extract pathname & search out of href.

@LucasMinet
Copy link

Hello @chriddyp ,

First of all thank you for your comments and research on this problem.
I am still experiencing the same problem.

I have a multi-page application, and as soon as the refresh property of dcc.Location is set to False, the url is updated according to my choice in a dropdown (via a callback), but the content of the page is not updated. I've been looking for a solution for several days but I can't find it.
The problem with keeping refresh=True is that first of all, ergonomically, the update of the page adds slowness to the interface and secondly, it makes me lose all the selected values in my dropdowns.

I hope you can help me with my problem. I thank you in advance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants