Skip to content

Commit 540a354

Browse files
apiadclaude
andcommitted
fix(engine): close out the two pinned bugs from slices 1+2
1. Reactive class= binding (markup.py): - Passing a state proxy to classes= (or .classes(), or via the new class_name= React-style alias) now emits class="<current>" AND data-bind-class="<state.path>" at SSR — picked up by the existing ReactiveRegistry updater in client.py. - class_name= is honored as an alias because `class` is a Python keyword; it routes through the same reactive-binding path. - examples/reactivity.py:73 (b.div(class_name=UiState.theme, ...)) now does what its author intended. 2. Bundle generator handles nested defs (app.py): - _generate_bundle now textwrap.dedent()s the source returned by inspect.getsource() for both state classes and user functions, and filters decorator lines with .strip().startswith("@"). Nested @app.local / @app.client.* defs (factories, tests) now produce valid bundles. Tests now positive on both behaviors; the two strict xfails are gone. 35 passed, 0 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1c78dc9 commit 540a354

3 files changed

Lines changed: 65 additions & 36 deletions

File tree

tests/test_engine.py

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,9 @@ def home():
136136
compile(r.text, "<violetear-bundle>", "exec")
137137

138138

139-
@pytest.mark.xfail(
140-
reason=(
141-
"Bundle generator (_generate_bundle in app.py:632-634) filters decorator lines "
142-
"with `c.startswith('@')` instead of `c.strip().startswith('@')`. When a client "
143-
"function is defined nested inside another function, its source comes back "
144-
"indented; the decorator line survives the filter and the bundle becomes invalid "
145-
"Python. Also affects state classes (similar pattern at line 617 uses .strip() "
146-
"for the decorator but still leaves class body indentation intact). Pinned as a "
147-
"known limitation: real-world usage defines @app.client.* and @app.local at "
148-
"module level, where this doesn't bite."
149-
),
150-
strict=True,
151-
)
152-
def test_bundle_rejects_nested_defs():
153-
"""Pin the known limitation: the bundle generator only supports module-level
154-
state classes and client functions; nested definitions produce invalid bundles."""
139+
def test_bundle_supports_nested_defs():
140+
"""Bundle generator dedents source so nested @app.local / @app.client defs
141+
(e.g. inside factories or tests) produce valid Python."""
155142
app = App(title="Nested", version="t6")
156143

157144
@app.local
@@ -169,22 +156,12 @@ def home():
169156

170157
client = TestClient(app.api)
171158
r = client.get("/_violetear/bundle.py")
159+
assert r.status_code == 200
172160
compile(r.text, "<violetear-bundle>", "exec")
173161

174162

175-
@pytest.mark.xfail(
176-
reason=(
177-
"examples/reactivity.py:73 uses class_name= on b.div(...). Element treats "
178-
"that as a raw attr (rendered as class_name=\"...\" + data-bind-class_name=...) "
179-
"instead of class=/data-bind-class. Reactive class bindings aren't supported "
180-
"by markup.py: self._classes lives outside self._attrs, so the proxy-check at "
181-
"render time never fires for the class= attribute. Pinned as a known gap "
182-
"rather than silently fixed."
183-
),
184-
strict=True,
185-
)
186-
def test_reactive_class_binding_emits_data_bind_class():
187-
"""Pin the known gap: there is no first-class way to reactively bind the class= attribute."""
163+
def test_reactive_class_binding_via_classes_kwarg():
164+
"""Passing a state proxy to classes= emits class= (static) + data-bind-class= (binding)."""
188165
app = App(title="Class Binding", version="t5")
189166

190167
@app.local
@@ -196,11 +173,39 @@ class UiState:
196173
def home():
197174
doc = Document(title="x")
198175
with doc.body as b:
199-
b.div(class_name=UiState.theme, id="app-container")
176+
b.div(classes=UiState.theme, id="app-container")
200177
return doc
201178

202179
client = TestClient(app.api)
203180
html = client.get("/").text
204181

205182
assert 'class="light"' in html
206183
assert 'data-bind-class="UiState.theme"' in html
184+
185+
186+
def test_reactive_class_binding_via_class_name_alias():
187+
"""`class_name=` (React-style) is honored as an alias for `classes=`.
188+
189+
Pythonistas reach for class_name because `class` is a reserved keyword;
190+
we accept it and route through the same reactive-binding path."""
191+
app = App(title="Class Name Alias", version="t7")
192+
193+
@app.local
194+
@dataclass
195+
class UiState:
196+
theme: str = "dark"
197+
198+
@app.view("/")
199+
def home():
200+
doc = Document(title="x")
201+
with doc.body as b:
202+
b.div(class_name=UiState.theme, id="x")
203+
return doc
204+
205+
client = TestClient(app.api)
206+
html = client.get("/").text
207+
208+
assert 'class="dark"' in html
209+
assert 'data-bind-class="UiState.theme"' in html
210+
# The alias should be consumed, not leaked as a literal HTML attribute.
211+
assert "class_name=" not in html

violetear/app.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,11 @@ def _generate_bundle(self) -> str:
605605
runtime_code = f.read()
606606

607607
# 6. Generate State Classes (with dataclass re-application)
608+
# dedent() lets us pull definitions from nested scopes (factories, tests)
609+
# — without it, indentation from the source file leaks into the bundle.
608610
state_code = []
609611
for name, cls in self.client.state_classes.items():
610-
source = inspect.getsource(cls)
612+
source = dedent(inspect.getsource(cls))
611613
lines = source.split("\n")
612614

613615
# Check for dataclass
@@ -630,8 +632,9 @@ def _generate_bundle(self) -> str:
630632
# 7. Extract User Functions
631633
user_code = []
632634
for name, func in self.client.code_functions.items():
633-
code = inspect.getsource(func).split("\n")
634-
code = [c for c in code if not c.startswith("@")]
635+
source = dedent(inspect.getsource(func))
636+
code = source.split("\n")
637+
code = [c for c in code if not c.strip().startswith("@")]
635638
user_code.append("\n".join(code))
636639

637640
# --- Safety Checks & Server Stubs ---

violetear/markup.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,19 @@ def __init__(
7676
self._id = id
7777
self._text = text
7878

79-
if isinstance(classes, str):
79+
# `class` is a Python keyword, so accept `class_name=` (React-style) as
80+
# an alias for the `classes=` parameter when the caller didn't pass one.
81+
if classes is None and "class_name" in attrs:
82+
classes = attrs.pop("class_name")
83+
84+
# Reactive class binding: if `classes` is a state proxy, emit both the
85+
# static value (split into the initial class list) and a `data-bind-class`
86+
# attribute at render time. _class_binding holds the state path.
87+
self._class_binding: str | None = None
88+
if hasattr(classes, "_path") and hasattr(classes, "current_value"):
89+
self._class_binding = classes._path
90+
classes = str(classes.current_value).split()
91+
elif isinstance(classes, str):
8092
classes = classes.split()
8193

8294
self._classes = list(classes or [])
@@ -107,10 +119,16 @@ def id(self, id: str) -> Self:
107119
return self
108120

109121
def classes(self, classes: Union[str, List[str]]) -> Self:
110-
if isinstance(classes, str):
122+
# Reactive binding: a state proxy passed here behaves like the ctor —
123+
# captures the binding path and renders the current value as the
124+
# initial class string.
125+
if hasattr(classes, "_path") and hasattr(classes, "current_value"):
126+
self._class_binding = classes._path
127+
classes = str(classes.current_value).split()
128+
elif isinstance(classes, str):
111129
classes = classes.split()
112130

113-
self._classes = classes
131+
self._classes = list(classes)
114132
return self
115133

116134
def style(self, style: Style) -> Self:
@@ -183,6 +201,9 @@ def _render(self, fp, indent: int):
183201
classes = " ".join(self._classes)
184202
parts.append(f'class="{classes}"')
185203

204+
if self._class_binding:
205+
parts.append(f'data-bind-class="{self._class_binding}"')
206+
186207
if self._style:
187208
parts.append(self._style.inline())
188209

0 commit comments

Comments
 (0)