Canceling Background Callback in Multi-page app

I’m working with multi-page apps and trying to understand how to cancel background callbacks on page refresh or page exit.

I created a simple Dash multi-page app to demonstrate the issue. Below is the project directory and code:

  • app.py - sets up a multi-page app with support for background callbacks that uses cache. Run locally.
  • pg0_home.py - the base path for the multi-page app. Shows a link to the other accessible page in this multi-page app.
  • pg1_number_counter.py - a simple application that uses a background callback to update a counter based on button click.
    C:.
    │ app.py

    └───pages
    │ pg0_home.py
    │ pg1_number_counter.py

    ├───cache
    │ cache.db

    └───__pycache__
    pg0_home.cpython-38.pyc
    pg1_number_counter.cpython-38.pyc

app.py

import dash
from dash import Dash, html, DiskcacheManager
import dash_bootstrap_components as dbc
import diskcache
import os

# support for background callbacks
cache_path = os.path.join(os.getcwd(), 'pages', 'cache')
cache = diskcache.Cache(cache_path)
background_callback_manager = DiskcacheManager(cache)

app = Dash(__name__,
           external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.themes.DARKLY, dbc.icons.BOOTSTRAP], 
           use_pages=True, suppress_callback_exceptions=True,
           background_callback_manager=background_callback_manager)

app.layout = html.Div([
    dash.page_container
])

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

pg0_home.py

import dash
from dash import html
import dash_bootstrap_components as dbc

dash.register_page(__name__,
                   path = '/',
                   title = 'Home',
                   name = 'Home',
                   description='Home')

layout = html.Div([
    dbc.Container([
        dbc.Row([
            html.H1("Welcome!", className="text-center"),
        ], style={'margin-top': '250px'}),
        dbc.Row([
            html.P(children=[html.A("Number counter", href="/number-counter")], className="text-center")
        ])
    ])
])

pg1_number_counter.py

import dash
from dash import html, dcc, Input, Output, State, callback
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
import time

dash.register_page(__name__,
                   path = '/number-counter',
                   title = 'Number Counter',
                   name = 'Number Counter',
                   description='Simple app demonstration')

layout = html.Div([
    dbc.Container([
        dbc.Row([
            dbc.Col(dcc.Link(html.H3("", className="bi bi-list"), href="/"), width={"size": 2}, className="mt-3")
        ]),
        dbc.Row([
            html.H3("0", id="current-count", className="text-center"),
        ], style={'margin-top': '250px'}),
        dbc.Row([
            dbc.Col([
                dbc.Button("Stop", id="trigger-btn", color="danger"),
            ], width="auto"),
        ], justify="center"),
        dbc.Row([
            dbc.Col([
                dcc.Location(id="pg-url", refresh=False),
            ])
        ])
    ])
])

@callback(
    Output('trigger-btn', 'children'), 
    Output('trigger-btn', 'color'),

    Input('trigger-btn', 'n_clicks'),
    State('trigger-btn', 'children'),
)
def on_click_change_styling(_, curr_btn_text):
    """
    Update the button styling each time the user clicks the button
    """
    if curr_btn_text == 'Start':
        return 'Stop', 'danger'
    elif curr_btn_text == 'Stop':
        return 'Start', 'primary'
    else:
        raise PreventUpdate

@callback(
    Output('current-count', 'children'),

    Input('trigger-btn', 'children'), 
    State('trigger-btn', 'color'),
    State('current-count', 'children'),
    background=True,
    cancel=[Input('trigger-btn', 'n_clicks'), Input("pg-url", "pathname"),],
    progress=[Output('current-count', 'children')],
    prevent_initial_call=True,  
)
def update_progress(set_progress, btn_text, btn_color, curr_count):
    """
    If the button says 'Stop', periodically update the count until the user clicks the button again
    """
    # calculations should only be done if btn has appropriate values
    if btn_text != 'Stop' or btn_color != 'danger':
        raise PreventUpdate
    row_counter = int(curr_count)
    while(True):
        # increase the count every second
        time.sleep(1)
        # update the row counter
        row_counter+=1
        print(f"row_counter = {row_counter}")
        # Update the user on the current progress of the counts
        set_progress(str(row_counter))

I have successfully set-up the logic of pg1_number_counter.py to cancel on 2 situations:

  1. The user explicitly cancels the background callback by updating the property listed in cancel: here, it is clicking the button.
    dash_pg_btn_click
  2. The user navigates to a different page in the multi-page app: since there are only 2 paths, this happens navigating from /number-counter to /
    dash_pg_path_change

However, there are 2 situations where I want to cancel the background callback but I can’t

  1. The user refreshes on path /number-counter
    dash_pg_refresh
  2. The user exits on path /number-counter
    dash_pg_exit

My current idea is this: I was able to create a simple html page that triggers a pop-up on page exit or page refresh.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Blank Page</title>
    <script>
        window.onbeforeunload = function() {
            return "";
        };
    </script>
</head>
<body>
</body>
</html>

html_pg_refresh_exit

If I could create a clientside callback with this pop-up logic, it would also include logic to update the button click if the btn is red and the user clicked ‘Reload’ or ‘Leave’ (I’ll just call it ‘confirm’). Below is rough pseudo-code (obviously doesn’t work)

clientside_callback(
    """
    function(pop_up, curr_btn_clicks, btn_color) {
        // html code is put somewhere here
        while(pop_up != None) {
            // only increase the btn click count if the background callback is still in progress
            if (window.pop_up_confirm == True && btn_color == 'danger') {
                return curr_btn_clicks + 1;
            }
        }
    }
    """,
    Output('trigger-btn', 'n_clicks'),
    Input('window', 'pop_up'),
    State('trigger-btn', 'n_clicks'),
    State('trigger-btn', 'color'),
    prevent_initial_call=True,
)

I’ve only ever used simple clientside callbacks that print to the console, so wondering if I’m going in the right direction? I see some examples of clientside callbacks here (Clientside Callbacks | Dash for Python Documentation | Plotly), but not addressing my specific case. Or is there a different approach altogether to cancel a background callback on page refresh or exit? Thanks!