@@ -709,3 +709,84 @@ def on_value_change(value):
709709 assert (
710710 fire_counts [2 ] == 1
711711 ), f"New item 2 callback should have fired exactly once, fired { fire_counts [2 ]} "
712+
713+
714+ def test_cbwc010_full_replace_fires_initial_callbacks (dash_duo ):
715+ """Regression test ensuring full-replacement (non-Patch) outputs still fire
716+ initial callbacks for all replaced components.
717+
718+ When a callback returns a full list (not a Patch), every component in the
719+ new layout is a fresh instance. The isUnchangedOutputProp suppression must
720+ NOT apply here: prePatchPaths is absent from the execution result, so
721+ oldPaths/oldLayout are never passed, and all initial calls must fire.
722+ """
723+ lock = threading .Lock ()
724+ fire_counts = {} # {index: count}
725+
726+ def make_item (index ):
727+ return html .Div (
728+ [
729+ dcc .Input (
730+ id = {"type" : "fr-input" , "index" : index },
731+ value = index ,
732+ type = "number" ,
733+ className = "fr-input" ,
734+ ),
735+ html .Div (
736+ "init" ,
737+ id = {"type" : "fr-output" , "index" : index },
738+ className = "fr-output" ,
739+ ),
740+ ]
741+ )
742+
743+ app = Dash (__name__ )
744+ app .layout = html .Div (
745+ [
746+ html .Button ("Replace" , id = "replace-btn" , n_clicks = 0 ),
747+ html .Div ([make_item (0 ), make_item (1 )], id = "fr-container" ),
748+ ]
749+ )
750+
751+ @app .callback (
752+ Output ("fr-container" , "children" ),
753+ Input ("replace-btn" , "n_clicks" ),
754+ prevent_initial_call = True ,
755+ )
756+ def replace_items (_ ):
757+ # Full replacement — returns a plain list, not a Patch
758+ return [make_item (10 ), make_item (11 )]
759+
760+ @app .callback (
761+ Output ({"type" : "fr-output" , "index" : MATCH }, "children" ),
762+ Input ({"type" : "fr-input" , "index" : MATCH }, "value" ),
763+ )
764+ def on_value_change (value ):
765+ from dash import ctx
766+
767+ idx = ctx .outputs_grouping ["id" ]["index" ]
768+ with lock :
769+ fire_counts [idx ] = fire_counts .get (idx , 0 ) + 1
770+ count = fire_counts [idx ]
771+ return f"fired-{ idx } -#{ count } "
772+
773+ dash_duo .start_server (app )
774+
775+ # Wait for the initial callbacks for items 0 and 1.
776+ wait .until (lambda : fire_counts .get (0 , 0 ) >= 1 , 5 )
777+ wait .until (lambda : fire_counts .get (1 , 0 ) >= 1 , 5 )
778+
779+ # Trigger a full replacement.
780+ dash_duo .find_element ("#replace-btn" ).click ()
781+
782+ # After full replacement, items 10 and 11 are brand-new instances and
783+ # MUST have their initial callbacks fire.
784+ wait .until (lambda : fire_counts .get (10 , 0 ) >= 1 , 5 )
785+ wait .until (lambda : fire_counts .get (11 , 0 ) >= 1 , 5 )
786+
787+ assert (
788+ fire_counts [10 ] >= 1
789+ ), f"New item 10 callback should have fired after full replace, got { fire_counts .get (10 , 0 )} "
790+ assert (
791+ fire_counts [11 ] >= 1
792+ ), f"New item 11 callback should have fired after full replace, got { fire_counts .get (11 , 0 )} "
0 commit comments