Skip to content

Commit 71e34b8

Browse files
fix: detect conditional hook usage when hook is used as decorator
The HookValidator only checked ast.Call nodes, so hooks used as decorators without parentheses (e.g. @solara.use_effect) were not flagged when inside conditional/loop/try scopes. This inspects decorator_list on FunctionDef/AsyncFunctionDef nodes as well. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent eb10f35 commit 71e34b8

File tree

2 files changed

+93
-2
lines changed

2 files changed

+93
-2
lines changed

solara/validate_hooks.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,31 @@ def visit_Try(self, node: ast.Try) -> t.Any:
169169
def visit_TryStar(self, node: ast.TryStar) -> t.Any:
170170
self._visit_children_using_scope(node)
171171

172+
def _check_decorator_hooks(self, node: t.Union[ast.FunctionDef, ast.AsyncFunctionDef]):
173+
"""Check if any decorators are hooks (e.g. @use_effect, @solara.use_effect, or @use_task(...))."""
174+
for decorator in node.decorator_list:
175+
id_ = None
176+
check_node = decorator
177+
if isinstance(decorator, ast.Call):
178+
# e.g. @use_task(dependencies=[...])
179+
check_node = decorator.func
180+
if isinstance(check_node, ast.Name):
181+
id_ = check_node.id
182+
elif isinstance(check_node, ast.Attribute):
183+
id_ = check_node.attr
184+
if id_ is not None and self.matches_use_function(id_):
185+
self.error_on_early_return(decorator, id_)
186+
self.error_on_invalid_scope(decorator, id_)
187+
172188
def visit_FunctionDef(self, node: ast.FunctionDef):
189+
self._check_decorator_hooks(node)
173190
old_function_scope = self.function_scope
174191
self.function_scope = node
175192
self._visit_children_using_scope(node)
176193
self.function_scope = old_function_scope
177194

178195
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
196+
self._check_decorator_hooks(node)
179197
old_function_scope = self.function_scope
180198
self.function_scope = node
181199
self._visit_children_using_scope(node)
@@ -213,7 +231,7 @@ def error_on_invalid_assign(self, node: ast.Assign):
213231
f"{self.get_source_context(line)}: {message}",
214232
)
215233

216-
def error_on_early_return(self, use_node: ast.Call, use_node_id: str):
234+
def error_on_early_return(self, use_node: ast.expr, use_node_id: str):
217235
"""
218236
Checks if the latest use of a reactive function occurs after the earliest return
219237
"""
@@ -231,7 +249,7 @@ def error_on_early_return(self, use_node: ast.Call, use_node_id: str):
231249
{_hint_supress(line, cause)}""",
232250
)
233251

234-
def error_on_invalid_scope(self, use_node: ast.Call, use_node_id: str):
252+
def error_on_invalid_scope(self, use_node: ast.expr, use_node_id: str):
235253
"""
236254
Checks if the latest use of a reactive function occurs after the earliest return or in an invalid scope
237255
such as try-except blocks.

tests/unit/hook_use_invalid_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,79 @@ def Page2(): # noqa: SH106
168168
pass
169169

170170

171+
def test_hook_use_invalid_conditional_decorator():
172+
with hook_check_raise(), pytest.raises(HookValidationError):
173+
174+
@solara.component
175+
def Page():
176+
if 1 > 3:
177+
178+
@solara.use_effect
179+
def effect():
180+
pass
181+
182+
solara.Text("Done")
183+
184+
with hook_check_warn(), pytest.warns(UserWarning):
185+
186+
@solara.component
187+
def Page2():
188+
if 1 > 3:
189+
190+
@solara.use_effect
191+
def effect():
192+
pass
193+
194+
solara.Text("Done")
195+
196+
@solara.component
197+
def Page3():
198+
if 1 > 3:
199+
200+
@solara.use_effect # noqa: SH102
201+
def effect():
202+
pass
203+
204+
solara.Text("Done")
205+
206+
207+
def test_hook_use_invalid_conditional_decorator_call():
208+
"""Test that @use_effect(dependencies=[...]) style call-decorators are also caught."""
209+
with hook_check_raise(), pytest.raises(HookValidationError):
210+
211+
@solara.component
212+
def Page():
213+
if 1 > 3:
214+
215+
@solara.use_effect(dependencies=[]) # type: ignore[call-arg]
216+
def task():
217+
pass
218+
219+
solara.Text("Done")
220+
221+
with hook_check_warn(), pytest.warns(UserWarning):
222+
223+
@solara.component
224+
def Page2():
225+
if 1 > 3:
226+
227+
@solara.use_effect(dependencies=[]) # type: ignore[call-arg]
228+
def task():
229+
pass
230+
231+
solara.Text("Done")
232+
233+
@solara.component
234+
def Page3():
235+
if 1 > 3:
236+
237+
@solara.use_effect(dependencies=[]) # type: ignore[call-arg] # noqa: SH102
238+
def task():
239+
pass
240+
241+
solara.Text("Done")
242+
243+
171244
def test_hook_use_invalid_assign():
172245
# sometimes we know that the use of a hook is stable, even when in a loop
173246

0 commit comments

Comments
 (0)