fix(render): match real dj-root/dj-view attribute, not substring, when picking VDOM source (#1746)#1747
Conversation
…n picking VDOM source (#1746) get_template() chose the VDOM source with a naive substring check ('dj-root' in template_source or 'dj-view' in template_source). When the real <div dj-root> lives in the BASE template and the CHILD only mentions the tokens as displayed text (e.g. a code example) — or as a substring of another word like adj-view — the check wrongly picked the child as the VDOM source. _extract_liveview_root_with_wrapper found no real dj-root there, so render_full_template's shell/dj-root replacement nested the whole page (two <!DOCTYPE>/two <footer>, truncated <script>, broken dj-navigate morphs). Replace the substring check with the anchored-attribute regexes already defined in the module (_DJ_ROOT_RE / _DJ_VIEW_RE), which require a REAL <div ... dj-root/dj-view ...> tag — so a child that merely displays the token in text won't match. The legitimate 'child declares its own real <div dj-root>' case (the reason the heuristic exists) still picks the child. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stage-11 Adversarial Review — PR #1747 (fix #1746)Verdict: 🟢 APPROVE. The change is correct, well-targeted, non-tautological, and — the key concern of this review — narrowing the predicate from a substring check to the Pre-review gates
Empirical verification (all run)
THE KEY EDGE CASE — does div-anchoring break non-div /
|
| child root shape | NEW (<div>-regex) |
OLD (substring) |
|---|---|---|
<body dj-view="..."> in child |
DOCTYPE=1 ✅ | DOCTYPE=2 ❌ (double-render) |
<section dj-root> in child |
DOCTYPE=1 ✅ | DOCTYPE=1 ✅ |
<div dj-root> in child (control) |
DOCTYPE=1 ✅ | DOCTYPE=1 ✅ |
The <body dj-view> case was broken under the old code (the substring matched dj-view, selected the child as vdom_source, extractor found no <div...dj-view>, page double-rendered) and is fixed by this PR. <section dj-root> worked under both because the substring matched but the extractor's div-regex found nothing and fell through to the resolved full-template path. Conclusion: the framework's dj-root is always <div dj-root>; non-div roots are uniformly unsupported across extraction + streaming, and this fix removes a gate inconsistency rather than introducing one. (The Rust VDOM find_liveview_root at crates/djust_vdom/src/parser.rs:379-396 is element-agnostic, but it runs on already-rendered/extracted HTML, not this selection path, so it's not in scope.)
Regex on template source (item 3): correct
template_source = template.template.source is the raw template with {% %}/{{ }} tags. Probed:
<div dj-root>{{ count }}</div>→ match ✅{% if x %}<div dj-root>{% endif %}→ match ✅<div data-dj-root>/<p>adj-view</p>/ displayed-text /<span dj-root>→ correctly no-match ✅- newline-separated attrs → match ✅
🟡 One minor, contrived narrowing (non-blocking)
<div {% if a %}dj-root{% endif %}> (dj-root attached conditionally via a template tag with no trailing space) → the OLD substring matched, the NEW regex does not (the { of {% endif %} immediately follows dj-root, failing the (?=[\s=>/]) lookahead). This is a non-idiomatic shape — djust authors write a literal <div dj-root> — and a trailing space (dj-root {% endif %}) makes it match again. Worth a one-line awareness note; not a real-world blocker.
Symbol-migration grep (item 4): complete
Only other production hit is checks.py:3341-3342 ("dj-view" in tag / "dj-root" in tag). Implementer's "fine" judgment is correct: tag is match.group(0) from tag_re = <[a-zA-Z][^>]*>, i.e. already a real extracted tag — displayed text / adj-view can't reach it. It's the T005 system-check linter, not a render path. Re-grepped both quote styles across python/djust/ — no other render/extraction-path instance missed.
Pre-existing failures (item 5): confirmed pre-existing, NOT caused by this PR
tests/unit/test_demo_views.py 4 failures (test_pwa_view_in_urlconf, test_tenant_view_in_urlconf, test_service_worker_url_resolves, test_manifest_url_resolves):
- Pass 43/43 in isolation on PR HEAD.
- Appear only in cross-dir invocation (
pytest python/ tests/unit/test_demo_views.py): 4 failed / 5615 passed on HEAD. - Identical 4 failures on
origin/mainwith the PR removed: 4 failed / 5608 passed. URLconf-pollution from sibling dirs, unrelated to this fix.
Counts
- New test: 7 passed. Gate-off: 4 failed / 3 passed (correct). No-regression suite: 26 passed. Cross-dir: 4 pre-existing fails on both HEAD and main.
Working tree restored to clean PR-branch HEAD (26756b1a); pre-existing untracked scripts/antigravity_pipeline_run.py left alone.
Retrospective — PR #1747 (#1746)Task: fix render_full_template double-render when a LiveView child template contains literal dj-root/dj-view text. Quality: 5/5One-line root-cause fix with a full variant repro, gate-off, and an edge-case review that turned up a bonus fix. What went well
What didn't
Process findingA downstream consumer (djust.org) surfaced a core-framework render bug that the framework's own tests + demo didn't catch — because no test rendered a LiveView whose reactive content displays djust attribute names. The minimal in-framework repro (filed in #1746 before the fix) is what made this a clean one-line fix instead of a guess. Reinforces: when a downstream app hits a framework bug, distill it to a minimal in-framework repro first. Verified
|
Root cause
python/djust/mixins/template.py:161-165(get_template()) chose the VDOM source with a naive substring check:"dj-root" in template_sourcematches the token anywhere — including documentation/example code that merely displaysdj-root/dj-view, or another word containing it as a substring (adj-view). When the real<div dj-root>lives in the base template (a common layout) and the child only mentions the tokens in text, this wrongly picks the child asvdom_source;_extract_liveview_root_with_wrapperfinds no real dj-root there → wrong VDOM template →render_full_template's shell/dj-root replacement nests the whole page (two<!DOCTYPE>/two<footer>, truncated<script>, brokendj-navigate).Fix
Replace the substring check with the anchored-attribute regexes already defined in the module (
_DJ_ROOT_RE/_DJ_VIEW_RE), which require a REAL<div ... dj-root/dj-view ...>tag:Both regexes are module-level and already in scope at line 161. A child that merely displays the token in text no longer matches; the legitimate "child declares its own real
<div dj-root>" case (the reason the heuristic exists) still pickstemplate_source.Variant repro table (regression test
test_render_full_template_djroot_substring_1746.py)<!DOCTYPE>before<p>hello</p>(control)<p>dj-view</p>(plain text)<p>dj-root</p>(plain text)<p><code>dj-view</code> and <code>dj-root</code></p><p>adj-view</p>(substring)<p>dj-click</p>(other dj-* attr, control)Plus a control proving the good path still works: a child that DOES declare its own real
<div dj-root>still renders exactly one<!DOCTYPE>/<footer>.Gate-off (#254)
Reverting the predicate to the naive substring (
if ("dj-root" in ... or "dj-view" in ...)) makes the 4 behavior-meaningful cases (dj-view/dj-root/code/adj-view) fail again (2 DOCTYPEs); restoring the regex makes them pass. The 3 control cases pass either way — confirming the 4 are non-tautological.Symbol-migration grep (canon)
grep -rn '"dj-root" in\|"dj-view" in' python/djust/found one other production instance:python/djust/checks.py:3341-3342(_check_view_root_same_element, T005). It is legitimately fine — it operates on text already matched to be inside an HTML tag (tag_reextracts<...>tags first, then checks substring within each tag), so it cannot match displayed text the way the render path did. Not in scope (#1079). The remaining matches are test assertions (test_mcp.py).Test results
test_ssr_render_normalization_1737.py+test_template_inheritance.py+test_context_processors_in_includes_1722.py+test_model_fk_rendering.py: 30/30 pass (the Initial SSR render skips the comment/whitespace normalization that render_with_diff applies → first-hydration morphChildren re-render (flash) #1737 parity tests stay green).python/djust/tests/: 3099 passed, 3 skipped.python/tests/+tests/unit/: 4206 passed; 4 pre-existing cross-dir-ordering pollution failures intests/unit/test_demo_views.py(urlconf registration) — verified pre-existing (pass in isolation, and fail identically on a cleanorigin/maintree with my changes stashed). Unrelated to this fix.Closes #1746