|
1 | 1 | import pytest |
2 | 2 | import re |
| 3 | +import threading |
3 | 4 | from selenium.webdriver.common.keys import Keys |
4 | 5 | import json |
5 | 6 | from multiprocessing import Lock |
6 | 7 |
|
7 | 8 | from dash.testing import wait |
8 | 9 | import dash |
9 | | -from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc |
| 10 | +from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc, Patch |
10 | 11 |
|
11 | 12 | from tests.assets.todo_app import todo_app |
12 | 13 | from tests.assets.grouping_app import grouping_app |
@@ -619,3 +620,92 @@ def on_click(_) -> str: |
619 | 620 | assert not dash_duo.find_element("#buttons button:nth-child(2)").get_attribute( |
620 | 621 | "disabled" |
621 | 622 | ) |
| 623 | + |
| 624 | + |
| 625 | +def test_cbwc009_patch_no_spurious_match_callbacks(dash_duo): |
| 626 | + """Regression test for the oldPaths fix in getUnfilteredLayoutCallbacks. |
| 627 | +
|
| 628 | + When Patch() appends a new MATCH-pattern component, existing MATCH callbacks |
| 629 | + must NOT re-fire for pre-existing components. Previously, crawlLayout would |
| 630 | + visit all children in the layout chunk and mark every matching output as |
| 631 | + initialCall=true, causing all existing callbacks to spuriously re-execute. |
| 632 | +
|
| 633 | + The fix passes oldPaths (the pre-update paths snapshot) into |
| 634 | + getUnfilteredLayoutCallbacks and skips initialCall for any component whose |
| 635 | + ID already exists in oldPaths. |
| 636 | + """ |
| 637 | + lock = threading.Lock() |
| 638 | + fire_counts = {} # {index: count} how many times each MATCH callback fired |
| 639 | + |
| 640 | + def make_item(index): |
| 641 | + return html.Div( |
| 642 | + [ |
| 643 | + dcc.Input( |
| 644 | + id={"type": "item-input", "index": index}, |
| 645 | + value=index, |
| 646 | + type="number", |
| 647 | + className="item-input", |
| 648 | + ), |
| 649 | + html.Div( |
| 650 | + "init", |
| 651 | + id={"type": "item-output", "index": index}, |
| 652 | + className="item-output", |
| 653 | + ), |
| 654 | + ] |
| 655 | + ) |
| 656 | + |
| 657 | + app = Dash(__name__) |
| 658 | + app.layout = html.Div( |
| 659 | + [ |
| 660 | + html.Button("Add", id="add-btn", n_clicks=0), |
| 661 | + html.Div([make_item(0), make_item(1)], id="container"), |
| 662 | + ] |
| 663 | + ) |
| 664 | + |
| 665 | + @app.callback( |
| 666 | + Output("container", "children"), |
| 667 | + Input("add-btn", "n_clicks"), |
| 668 | + prevent_initial_call=True, |
| 669 | + ) |
| 670 | + def add_item(n): |
| 671 | + p = Patch() |
| 672 | + p.append(make_item(n + 1)) |
| 673 | + return p |
| 674 | + |
| 675 | + @app.callback( |
| 676 | + Output({"type": "item-output", "index": MATCH}, "children"), |
| 677 | + Input({"type": "item-input", "index": MATCH}, "value"), |
| 678 | + ) |
| 679 | + def on_value_change(value): |
| 680 | + from dash import ctx |
| 681 | + |
| 682 | + idx = ctx.outputs_grouping["id"]["index"] |
| 683 | + with lock: |
| 684 | + fire_counts[idx] = fire_counts.get(idx, 0) + 1 |
| 685 | + count = fire_counts[idx] |
| 686 | + return f"fired-{idx}-#{count}" |
| 687 | + |
| 688 | + dash_duo.start_server(app) |
| 689 | + |
| 690 | + # Wait for the initial callbacks to fire for both pre-existing items. |
| 691 | + wait.until(lambda: fire_counts.get(0, 0) >= 1, 5) |
| 692 | + wait.until(lambda: fire_counts.get(1, 0) >= 1, 5) |
| 693 | + |
| 694 | + counts_before = {0: fire_counts[0], 1: fire_counts[1]} |
| 695 | + |
| 696 | + # Add a new item via Patch, this should fire only for index 2. |
| 697 | + dash_duo.find_element("#add-btn").click() |
| 698 | + wait.until(lambda: fire_counts.get(2, 0) >= 1, 5) |
| 699 | + |
| 700 | + # Pre-existing callbacks must NOT have re-fired. |
| 701 | + assert fire_counts[0] == counts_before[0], ( |
| 702 | + f"Item 0 callback fired spuriously after Patch: " |
| 703 | + f"was {counts_before[0]}, now {fire_counts[0]}" |
| 704 | + ) |
| 705 | + assert fire_counts[1] == counts_before[1], ( |
| 706 | + f"Item 1 callback fired spuriously after Patch: " |
| 707 | + f"was {counts_before[1]}, now {fire_counts[1]}" |
| 708 | + ) |
| 709 | + assert ( |
| 710 | + fire_counts[2] == 1 |
| 711 | + ), f"New item 2 callback should have fired exactly once, fired {fire_counts[2]}" |
0 commit comments