diff --git a/dash/_utils.py b/dash/_utils.py index b97bd6f03f..719d6614ed 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -144,9 +144,9 @@ def first(self, *names): def create_callback_id(output, inputs, no_output=False): - # A single dot within a dict id key or value is OK - # but in case of multiple dots together escape each dot - # with `\` so we don't mistake it for multi-outputs + # Dots within a string ID are escaped with \. to distinguish them + # from the separator between component-id and property. + # For dict IDs (JSON strings) we use \u002e instead - see _concat. hashed_inputs = None def _hash_inputs(): @@ -156,7 +156,17 @@ def _hash_inputs(): def _concat(x): nonlocal hashed_inputs - _id = x.component_id_str().replace(".", "\\.") + "." + x.component_property + id_str = x.component_id_str() + if isinstance(x.component_id, dict): + # Dict IDs are serialized as JSON strings. Using \. to escape + # dots produces an invalid JSON escape sequence that causes + # JSON.parse to throw SyntaxError in the frontend (see #3480). + # \u002e is the valid JSON Unicode escape for "." and is + # transparently decoded back to "." by JSON.parse. + escaped = id_str.replace(".", "\\u002e") + else: + escaped = id_str.replace(".", "\\.") + _id = escaped + "." + x.component_property if x.allow_duplicate: if not hashed_inputs: hashed_inputs = _hash_inputs() diff --git a/tests/unit/test_callback_unit.py b/tests/unit/test_callback_unit.py index a44336cbe5..1dab085f7f 100644 --- a/tests/unit/test_callback_unit.py +++ b/tests/unit/test_callback_unit.py @@ -1,128 +1,165 @@ -"""Unit tests for callback decorator behavior - no browser required.""" -import inspect - -import dash -from dash import Input, Output, State, callback - - -def test_callback_returns_callable(): - """Test that callback returns a callable decorator.""" - decorator = callback(Output("output", "children"), Input("input", "value")) - assert callable(decorator) - - -def test_callback_decorates_function(): - """Test that callback can decorate a function.""" - - @callback(Output("output", "children"), Input("input", "value")) - def my_callback(value): - return f"Value: {value}" - - assert callable(my_callback) - assert my_callback.__name__ == "my_callback" - - -def test_callback_signature_includes_typed_options(): - """Test that callback exposes the expected decorator keyword arguments.""" - sig = inspect.signature(callback) - - expected = { - "background", - "interval", - "progress", - "progress_default", - "running", - "cancel", - "manager", - "cache_args_to_ignore", - "cache_ignore_triggered", - "on_error", - "api_endpoint", - "optional", - "hidden", - } - assert expected.issubset(set(sig.parameters)) - - -def test_callback_with_multiple_inputs(): - """Test callback with multiple inputs.""" - - @callback( - Output("output", "children"), - Input("input1", "value"), - Input("input2", "value"), - ) - def multi_input_callback(val1, val2): - return f"{val1} + {val2}" - - assert callable(multi_input_callback) - - -def test_callback_with_state(): - """Test callback with State.""" - - @callback( - Output("output", "children"), - Input("input", "value"), - State("state", "value"), - ) - def callback_with_state(input_val, state_val): - return f"{input_val} - {state_val}" - - assert callable(callback_with_state) - - -def test_callback_with_multiple_outputs(): - """Test callback with multiple outputs.""" - - @callback( - Output("output1", "children"), - Output("output2", "children"), - Input("input", "value"), - ) - def multi_output_callback(value): - return value, f"Copy: {value}" - - assert callable(multi_output_callback) - - -def test_callback_preserves_docstring(): - """Test that callback preserves the wrapped function's docstring.""" - - @callback(Output("output", "children"), Input("input", "value")) - def documented_callback(value): - """This is a documented callback.""" - return value - - assert documented_callback.__doc__ == "This is a documented callback." - - -def test_callback_with_prevent_initial_call(): - """Test callback with prevent_initial_call parameter.""" - - @callback( - Output("output", "children"), - Input("input", "value"), - prevent_initial_call=True, - ) - def callback_no_initial(value): - return value - - assert callable(callback_no_initial) - - -def test_callback_with_background_params(): - """Test that callback accepts background callback parameters.""" - decorator = callback( - Output("output", "children"), - Input("input", "value"), - background=False, - interval=1000, - ) - assert callable(decorator) - - -def test_callback_module_export(): - """Test that callback is properly exported from dash module.""" - assert hasattr(dash, "callback") - assert dash.callback is callback +"""Unit tests for callback decorator behavior - no browser required.""" +import inspect +import json + +import dash +from dash import Input, Output, State, callback +from dash._utils import create_callback_id + + +def test_callback_returns_callable(): + """Test that callback returns a callable decorator.""" + decorator = callback(Output("output", "children"), Input("input", "value")) + assert callable(decorator) + + +def test_callback_decorates_function(): + """Test that callback can decorate a function.""" + + @callback(Output("output", "children"), Input("input", "value")) + def my_callback(value): + return f"Value: {value}" + + assert callable(my_callback) + assert my_callback.__name__ == "my_callback" + + +def test_callback_signature_includes_typed_options(): + """Test that callback exposes the expected decorator keyword arguments.""" + sig = inspect.signature(callback) + + expected = { + "background", + "interval", + "progress", + "progress_default", + "running", + "cancel", + "manager", + "cache_args_to_ignore", + "cache_ignore_triggered", + "on_error", + "api_endpoint", + "optional", + "hidden", + } + assert expected.issubset(set(sig.parameters)) + + +def test_callback_with_multiple_inputs(): + """Test callback with multiple inputs.""" + + @callback( + Output("output", "children"), + Input("input1", "value"), + Input("input2", "value"), + ) + def multi_input_callback(val1, val2): + return f"{val1} + {val2}" + + assert callable(multi_input_callback) + + +def test_callback_with_state(): + """Test callback with State.""" + + @callback( + Output("output", "children"), + Input("input", "value"), + State("state", "value"), + ) + def callback_with_state(input_val, state_val): + return f"{input_val} - {state_val}" + + assert callable(callback_with_state) + + +def test_callback_with_multiple_outputs(): + """Test callback with multiple outputs.""" + + @callback( + Output("output1", "children"), + Output("output2", "children"), + Input("input", "value"), + ) + def multi_output_callback(value): + return value, f"Copy: {value}" + + assert callable(multi_output_callback) + + +def test_callback_preserves_docstring(): + """Test that callback preserves the wrapped function's docstring.""" + + @callback(Output("output", "children"), Input("input", "value")) + def documented_callback(value): + """This is a documented callback.""" + return value + + assert documented_callback.__doc__ == "This is a documented callback." + + +def test_callback_with_prevent_initial_call(): + """Test callback with prevent_initial_call parameter.""" + + @callback( + Output("output", "children"), + Input("input", "value"), + prevent_initial_call=True, + ) + def callback_no_initial(value): + return value + + assert callable(callback_no_initial) + + +def test_callback_with_background_params(): + """Test that callback accepts background callback parameters.""" + decorator = callback( + Output("output", "children"), + Input("input", "value"), + background=False, + interval=1000, + ) + assert callable(decorator) + + +def test_callback_module_export(): + """Test that callback is properly exported from dash module.""" + assert hasattr(dash, "callback") + assert dash.callback is callback + + +def test_create_callback_id_escapes_dots_in_string_id(): + """A dot in a plain string component id is escaped with a backslash.""" + output = Output("my.component", "children") + callback_id = create_callback_id(output, []) + + assert callback_id == "my\\.component.children" + + +def test_create_callback_id_escapes_dots_in_dict_id_as_json_unicode(): + """A dot in a dict id must use the JSON \\u002e escape, not \\., + otherwise the frontend's JSON.parse throws a SyntaxError when it + un-escapes the id portion of the callback id string (see #3480).""" + output = Output({"type": "my.type", "index": 1}, "children") + callback_id = create_callback_id(output, []) + + id_part, prop_part = callback_id.rsplit(".", 1) + assert prop_part == "children" + # The escaped id must not contain a raw backslash-dot sequence... + assert "\\." not in id_part + # ...and must be valid JSON once the . escape is present verbatim. + assert "\\u002e" in id_part + parsed = json.loads(id_part) + assert parsed == {"type": "my.type", "index": 1} + + +def test_create_callback_id_dict_id_without_dots_unaffected(): + """Dict ids with no dots in their values still round-trip through JSON.""" + output = Output({"type": "widget", "index": 2}, "value") + callback_id = create_callback_id(output, []) + + id_part, prop_part = callback_id.rsplit(".", 1) + assert prop_part == "value" + assert json.loads(id_part) == {"type": "widget", "index": 2}