Skip to content

fix(render): match real dj-root/dj-view attribute, not substring, when picking VDOM source (#1746)#1747

Merged
johnrtipton merged 2 commits into
mainfrom
fix/render-full-template-naive-djroot-substring-1746
Jun 7, 2026
Merged

fix(render): match real dj-root/dj-view attribute, not substring, when picking VDOM source (#1746)#1747
johnrtipton merged 2 commits into
mainfrom
fix/render-full-template-naive-djroot-substring-1746

Conversation

@johnrtipton

Copy link
Copy Markdown
Contributor

Root cause

python/djust/mixins/template.py:161-165 (get_template()) chose the VDOM source with a naive substring check:

vdom_source = (
    template_source                                   # the CHILD template
    if ("dj-root" in template_source or "dj-view" in template_source)
    else resolved                                     # the full resolved (base+child) template
)

"dj-root" in template_source matches the token anywhere — including documentation/example code that merely displays dj-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 as vdom_source; _extract_liveview_root_with_wrapper finds 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>, broken dj-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:

vdom_source = (
    template_source
    if (_DJ_ROOT_RE.search(template_source) or _DJ_VIEW_RE.search(template_source))
    else resolved
)

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 picks template_source.

Variant repro table (regression test test_render_full_template_djroot_substring_1746.py)

child content <!DOCTYPE> before after
<p>hello</p> (control) 1 1
<p>dj-view</p> (plain text) 2 1
<p>dj-root</p> (plain text) 2 1
<p><code>dj-view</code> and <code>dj-root</code></p> 2 1
<p>adj-view</p> (substring) 2 1
<p>dj-click</p> (other dj-* attr, control) 1 1

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_re extracts <...> 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

Closes #1746

johnrtipton and others added 2 commits June 6, 2026 22:26
…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>
@johnrtipton

Copy link
Copy Markdown
Contributor Author

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 <div>-anchored regexes does not break any legitimate root-declaration shape. In fact the <body dj-view> shape it was feared to regress is one the fix also fixes.

Pre-review gates

  • Stale-base (Full HTML update path mangles page rendering #250): PASS. BEHIND=0 vs origin/main. git log origin/main..HEAD shows exactly the 2 PR commits.
  • Two-commit shape: PASS. 25c15909 = fix (mixins/template.py) + test (test_render_full_template_djroot_substring_1746.py), no CHANGELOG. 26756b1a = CHANGELOG-only, well-formed ### Fixed entry under [Unreleased].

Empirical verification (all run)

  1. New test passes: 7/7 green on HEAD.
  2. Gate-off (non-tautological): reverting only the predicate back to the substring in check → exactly 4 fail (dj_view_text, dj_root_text, dj_root_view_code, substring_adj_view, all 2 <!DOCTYPE>), 3 controls pass (control_plain, control_dj_click, child-own-<div dj-root>). The tests genuinely exercise the change.
  3. No regression: test_ssr_render_normalization_1737.py + test_template_inheritance.py + test_context_processors_in_includes_1722.py26 passed.

THE KEY EDGE CASE — does div-anchoring break non-div / <body dj-view> roots? No — verified empirically and structurally.

The predicate is just a gate in front of _extract_liveview_root_with_wrapper, which is the function that actually consumes the chosen vdom_source. That extractor (template.py:739-741) is already div-only: <div\s+[^>]*dj-root[^>]*> / <div\s+[^>]*dj-view[^>]*>. So is the streaming chunker (_DJ_ROOT_RE at template.py:434) and the shell-replacement path (template.py:972). The entire Python render/extraction pipeline has always been div-only. The new gate is now consistent with the extractor it feeds — the old substring gate was the lone outlier that could select a non-div child the extractor couldn't then use.

Empirical harness (base owns real <div dj-root>; child shapes vary), NEW code vs OLD substring code:

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/main with 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.

@johnrtipton johnrtipton merged commit 0503818 into main Jun 7, 2026
13 checks passed
@johnrtipton

Copy link
Copy Markdown
Contributor Author

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/5

One-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

  • Root cause was nailed before coding (template.py:161-165 naive "dj-root" in template_source substring gate) via the downstream-driven investigation on djust.org, with a minimal in-framework repro (the variant table) filed in render_full_template double-renders the whole page when a LiveView's child template contains the literal text "dj-root"/"dj-view" (e.g. in a code example) #1746. Implementer verified the line + that _DJ_ROOT_RE/_DJ_VIEW_RE were in scope, then made the one-line change.
  • The fix is consistency, not just a patch: the entire Python render/extraction pipeline (_extract_liveview_root_with_wrapper, the streaming chunker, the shell-replacement) was already <div>-only; the substring gate was the lone outlier that could pick a non-div child the extractor couldn't use. Anchoring the gate to _DJ_ROOT_RE/_DJ_VIEW_RE makes it consistent with everything downstream.
  • Stage-11 edge-case hunt found a bonus: narrowing to the div-regex doesn't just avoid breaking <body dj-view> children — it fixes them (they double-rendered under the old substring gate too). No legitimate root shape regressed.
  • Gate-off (Add remaining 13 Django built-in template filters #254): reverting the predicate fails exactly the 4 behavior-meaningful cases (dj-view/dj-root/code/adj-view = 2 DOCTYPEs); 3 controls pass either way — non-tautological.
  • Symbol-migration grep (canon): found the only other "dj-root" in/"dj-view" in production hit (checks.py:3341, T005) and correctly judged it safe (operates on extracted tags, not displayed text).

What didn't

Process finding

A 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

RETRO_COMPLETE

@johnrtipton johnrtipton deleted the fix/render-full-template-naive-djroot-substring-1746 branch June 7, 2026 02:40
johnrtipton added a commit that referenced this pull request Jun 8, 2026
…dening)

Drains #1751 (close-side </div> + scanner consolidation) and #1752 items 2-3
into v1.0.3; records #1747/#1750/#1748/#1753 already merged toward it.

Audit-bypass-reason: docs-only ROADMAP update via pipeline-drain skill (no retro needed)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant