Skip to content

Commit 2595fdb

Browse files
authored
Merge pull request #3923 from Mukller/fix/dict-id-dot-escape
fix(callbacks): use . for dots in dict ID callback IDs to fix JSON.parse SyntaxError
2 parents 7665f2a + 7558400 commit 2595fdb

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

‎dash/_utils.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ def first(self, *names):
144144

145145

146146
def create_callback_id(output, inputs, no_output=False):
147-
# A single dot within a dict id key or value is OK
148-
# but in case of multiple dots together escape each dot
149-
# with `\` so we don't mistake it for multi-outputs
147+
# Dots within a string ID are escaped with \. to distinguish them
148+
# from the separator between component-id and property.
149+
# For dict IDs (JSON strings) we use \u002e instead - see _concat.
150150
hashed_inputs = None
151151

152152
def _hash_inputs():
@@ -156,7 +156,17 @@ def _hash_inputs():
156156

157157
def _concat(x):
158158
nonlocal hashed_inputs
159-
_id = x.component_id_str().replace(".", "\\.") + "." + x.component_property
159+
id_str = x.component_id_str()
160+
if isinstance(x.component_id, dict):
161+
# Dict IDs are serialized as JSON strings. Using \. to escape
162+
# dots produces an invalid JSON escape sequence that causes
163+
# JSON.parse to throw SyntaxError in the frontend (see #3480).
164+
# \u002e is the valid JSON Unicode escape for "." and is
165+
# transparently decoded back to "." by JSON.parse.
166+
escaped = id_str.replace(".", "\\u002e")
167+
else:
168+
escaped = id_str.replace(".", "\\.")
169+
_id = escaped + "." + x.component_property
160170
if x.allow_duplicate:
161171
if not hashed_inputs:
162172
hashed_inputs = _hash_inputs()

‎tests/unit/test_callback_unit.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Unit tests for callback decorator behavior - no browser required."""
22
import inspect
3+
import json
34

45
import dash
56
from dash import Input, Output, State, callback
7+
from dash._utils import create_callback_id
68

79

810
def test_callback_returns_callable():
@@ -126,3 +128,38 @@ def test_callback_module_export():
126128
"""Test that callback is properly exported from dash module."""
127129
assert hasattr(dash, "callback")
128130
assert dash.callback is callback
131+
132+
133+
def test_create_callback_id_escapes_dots_in_string_id():
134+
"""A dot in a plain string component id is escaped with a backslash."""
135+
output = Output("my.component", "children")
136+
callback_id = create_callback_id(output, [])
137+
138+
assert callback_id == "my\\.component.children"
139+
140+
141+
def test_create_callback_id_escapes_dots_in_dict_id_as_json_unicode():
142+
"""A dot in a dict id must use the JSON \\u002e escape, not \\.,
143+
otherwise the frontend's JSON.parse throws a SyntaxError when it
144+
un-escapes the id portion of the callback id string (see #3480)."""
145+
output = Output({"type": "my.type", "index": 1}, "children")
146+
callback_id = create_callback_id(output, [])
147+
148+
id_part, prop_part = callback_id.rsplit(".", 1)
149+
assert prop_part == "children"
150+
# The escaped id must not contain a raw backslash-dot sequence...
151+
assert "\\." not in id_part
152+
# ...and must be valid JSON once the . escape is present verbatim.
153+
assert "\\u002e" in id_part
154+
parsed = json.loads(id_part)
155+
assert parsed == {"type": "my.type", "index": 1}
156+
157+
158+
def test_create_callback_id_dict_id_without_dots_unaffected():
159+
"""Dict ids with no dots in their values still round-trip through JSON."""
160+
output = Output({"type": "widget", "index": 2}, "value")
161+
callback_id = create_callback_id(output, [])
162+
163+
id_part, prop_part = callback_id.rsplit(".", 1)
164+
assert prop_part == "value"
165+
assert json.loads(id_part) == {"type": "widget", "index": 2}

0 commit comments

Comments
 (0)