Skip to content

Commit 472508c

Browse files
cadamsdotcomclaude
andcommitted
feat: Add pre-commit check for Radix Dialog <p> nesting violations
New check_frontend_code_quality.py script detects <p> elements nested inside AlertDialog.Description or Dialog.Description (which render as <p> by default), causing hydration warnings. Correctly skips blocks that use asChild. Handles multi-line opening tags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4572d00 commit 472508c

3 files changed

Lines changed: 273 additions & 0 deletions

File tree

.pre-commit-config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ repos:
129129
language: system
130130
pass_filenames: false
131131
always_run: true
132+
- id: check-frontend-code-quality
133+
name: Check frontend code quality (dialog nesting)
134+
entry: uv run python scripts/check_frontend_code_quality.py
135+
language: system
136+
files: \.tsx$
137+
pass_filenames: true
132138

133139
- repo: https://github.com/jendrikseipp/vulture
134140
rev: v2.13
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Check frontend code quality.
4+
5+
Current checks:
6+
1. No <p> nesting inside Radix Dialog/AlertDialog Description (renders as <p>)
7+
- use asChild with <div> wrapper, or replace <p> with <span className="block">
8+
"""
9+
10+
import re
11+
import sys
12+
from pathlib import Path
13+
14+
Violation = tuple[int, str, str]
15+
16+
DESCRIPTION_OPEN_RE = re.compile(r"<(AlertDialog|Dialog)\.Description\b")
17+
DESCRIPTION_CLOSE_RE = re.compile(r"</(AlertDialog|Dialog)\.Description>")
18+
AS_CHILD_RE = re.compile(r"\basChild\b")
19+
PARAGRAPH_TAG_RE = re.compile(r"<p[\s>/]")
20+
TAG_END_RE = re.compile(r"(?<!/)>")
21+
SELF_CLOSING_TAG_RE = re.compile(r"/\s*>")
22+
23+
24+
def _check_dialog_description_nesting(source: str) -> list[Violation]:
25+
"""Find <p> tags nested inside Description without asChild."""
26+
lines = source.splitlines()
27+
violations: list[Violation] = []
28+
29+
i = 0
30+
while i < len(lines):
31+
line = lines[i]
32+
open_match = DESCRIPTION_OPEN_RE.search(line)
33+
if not open_match:
34+
i += 1
35+
continue
36+
37+
# Collect the full opening tag (may span multiple lines)
38+
tag_lines = [line]
39+
# Check if the tag closes on this line
40+
rest_of_line = line[open_match.start() :]
41+
while not TAG_END_RE.search(rest_of_line) and not SELF_CLOSING_TAG_RE.search(
42+
rest_of_line
43+
):
44+
i += 1
45+
if i >= len(lines):
46+
break
47+
tag_lines.append(lines[i])
48+
rest_of_line = lines[i]
49+
50+
full_tag = "\n".join(tag_lines)
51+
52+
# Self-closing tag - no children possible
53+
if SELF_CLOSING_TAG_RE.search(full_tag):
54+
i += 1
55+
continue
56+
57+
# Has asChild - children render under a different element
58+
if AS_CHILD_RE.search(full_tag):
59+
# Skip to closing tag
60+
i += 1
61+
while i < len(lines):
62+
if DESCRIPTION_CLOSE_RE.search(lines[i]):
63+
break
64+
i += 1
65+
i += 1
66+
continue
67+
68+
# Scan content until closing tag for <p> elements
69+
i += 1
70+
while i < len(lines):
71+
if DESCRIPTION_CLOSE_RE.search(lines[i]):
72+
break
73+
if PARAGRAPH_TAG_RE.search(lines[i]):
74+
violations.append(
75+
(
76+
i + 1, # 1-indexed line number
77+
"dialog-p-nesting",
78+
'<p> inside Description (renders as <p>) - use asChild with <div>, or <span className="block">',
79+
)
80+
)
81+
i += 1
82+
i += 1
83+
84+
return violations
85+
86+
87+
def check_source(source: str, filename: str) -> list[Violation]:
88+
"""Check source for all applicable frontend violations."""
89+
violations: list[Violation] = []
90+
violations.extend(_check_dialog_description_nesting(source))
91+
return violations
92+
93+
94+
def check_file(file_path: Path) -> list[Violation]:
95+
"""Check a single file for violations."""
96+
try:
97+
source = file_path.read_text(encoding="utf-8")
98+
except (OSError, UnicodeDecodeError):
99+
return []
100+
return check_source(source, str(file_path))
101+
102+
103+
def main() -> None:
104+
"""Scan frontend source files and report violations."""
105+
if len(sys.argv) > 1:
106+
files = [Path(arg) for arg in sys.argv[1:]]
107+
else:
108+
files = sorted(Path("src").glob("**/*.tsx"))
109+
110+
total = 0
111+
for fp in files:
112+
if not fp.is_file():
113+
continue
114+
violations = check_file(fp)
115+
if violations:
116+
print(f"\n{fp}:")
117+
for lineno, vtype, desc in violations:
118+
print(f" Line {lineno} [{vtype}]: {desc}")
119+
total += 1
120+
121+
if total > 0:
122+
print(f"\nFound {total} frontend code quality violations.")
123+
sys.exit(1)
124+
else:
125+
print("No frontend code quality violations found.")
126+
sys.exit(0)
127+
128+
129+
if __name__ == "__main__":
130+
main()
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Tests for check_frontend_code_quality.py - frontend code quality checks."""
2+
3+
from scripts.check_frontend_code_quality import check_source
4+
5+
6+
class TestDialogDescriptionNesting:
7+
"""Detect <p> elements nested inside Radix Dialog/AlertDialog Description."""
8+
9+
def test_flags_p_inside_alert_dialog_description(self) -> None:
10+
source = (
11+
'<AlertDialog.Description className="mt-3 text-sm">\n'
12+
" <p>Are you sure?</p>\n"
13+
"</AlertDialog.Description>\n"
14+
)
15+
violations = check_source(source, "src/components/Example.tsx")
16+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
17+
assert len(nesting_violations) == 1
18+
assert nesting_violations[0][0] == 2 # line number of <p>
19+
20+
def test_flags_p_inside_dialog_description(self) -> None:
21+
source = (
22+
'<Dialog.Description className="mt-3 text-sm">\n'
23+
" <p>Some content</p>\n"
24+
"</Dialog.Description>\n"
25+
)
26+
violations = check_source(source, "src/components/Example.tsx")
27+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
28+
assert len(nesting_violations) == 1
29+
30+
def test_flags_multiple_p_tags(self) -> None:
31+
source = (
32+
'<AlertDialog.Description className="mt-3">\n'
33+
" <p>First paragraph</p>\n"
34+
" <p>Second paragraph</p>\n"
35+
"</AlertDialog.Description>\n"
36+
)
37+
violations = check_source(source, "src/components/Example.tsx")
38+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
39+
assert len(nesting_violations) == 2
40+
41+
def test_allows_as_child_with_div_wrapper(self) -> None:
42+
source = (
43+
"<AlertDialog.Description asChild>\n"
44+
' <div className="mt-3 text-sm">\n'
45+
" <p>This is fine because asChild renders as div</p>\n"
46+
" </div>\n"
47+
"</AlertDialog.Description>\n"
48+
)
49+
violations = check_source(source, "src/components/Example.tsx")
50+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
51+
assert len(nesting_violations) == 0
52+
53+
def test_allows_text_only_content(self) -> None:
54+
source = (
55+
'<AlertDialog.Description className="mt-3 text-sm">\n'
56+
" This document will be removed from the upload list.\n"
57+
"</AlertDialog.Description>\n"
58+
)
59+
violations = check_source(source, "src/components/Example.tsx")
60+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
61+
assert len(nesting_violations) == 0
62+
63+
def test_allows_span_block_instead_of_p(self) -> None:
64+
source = (
65+
'<AlertDialog.Description className="mt-3 text-sm">\n'
66+
' <span className="block mb-2">\n'
67+
" Are you sure you want to delete this?\n"
68+
" </span>\n"
69+
' <span className="block">\n'
70+
" This action cannot be undone.\n"
71+
" </span>\n"
72+
"</AlertDialog.Description>\n"
73+
)
74+
violations = check_source(source, "src/components/Example.tsx")
75+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
76+
assert len(nesting_violations) == 0
77+
78+
def test_handles_as_child_on_separate_line(self) -> None:
79+
source = (
80+
"<AlertDialog.Description\n"
81+
" asChild\n"
82+
' className="mt-3"\n'
83+
">\n"
84+
' <div className="space-y-2">\n'
85+
" <p>Safe because asChild is present</p>\n"
86+
" </div>\n"
87+
"</AlertDialog.Description>\n"
88+
)
89+
violations = check_source(source, "src/components/Example.tsx")
90+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
91+
assert len(nesting_violations) == 0
92+
93+
def test_flags_p_with_multi_line_opening_tag_without_as_child(self) -> None:
94+
source = (
95+
"<AlertDialog.Description\n"
96+
' className="mt-3 text-sm text-brand-mid-grey"\n'
97+
">\n"
98+
" <p>This is a violation</p>\n"
99+
"</AlertDialog.Description>\n"
100+
)
101+
violations = check_source(source, "src/components/Example.tsx")
102+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
103+
assert len(nesting_violations) == 1
104+
assert nesting_violations[0][0] == 4
105+
106+
def test_handles_self_closing_description(self) -> None:
107+
source = '<AlertDialog.Description className="sr-only" />\n'
108+
violations = check_source(source, "src/components/Example.tsx")
109+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
110+
assert len(nesting_violations) == 0
111+
112+
def test_handles_multiple_descriptions_in_one_file(self) -> None:
113+
source = (
114+
"// First dialog - safe (text only)\n"
115+
'<AlertDialog.Description className="mt-2">\n'
116+
" Are you sure?\n"
117+
"</AlertDialog.Description>\n"
118+
"\n"
119+
"// Second dialog - violation\n"
120+
'<AlertDialog.Description className="mt-2">\n'
121+
" <p>Bad nesting</p>\n"
122+
"</AlertDialog.Description>\n"
123+
)
124+
violations = check_source(source, "src/components/Example.tsx")
125+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
126+
assert len(nesting_violations) == 1
127+
assert nesting_violations[0][0] == 8
128+
129+
def test_p_with_attributes_is_also_flagged(self) -> None:
130+
source = (
131+
'<AlertDialog.Description className="mt-3">\n'
132+
' <p className="text-sm">Content</p>\n'
133+
"</AlertDialog.Description>\n"
134+
)
135+
violations = check_source(source, "src/components/Example.tsx")
136+
nesting_violations = [v for v in violations if v[1] == "dialog-p-nesting"]
137+
assert len(nesting_violations) == 1

0 commit comments

Comments
 (0)