Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_callback_unit.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""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():
Expand Down Expand Up @@ -126,3 +128,38 @@ 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}