Skip to content

fix(templates): put each render together over its extends chain as Django places blocks - #287

Merged
SizzleUnrlsd merged 6 commits into
mainfrom
fix/285-block-placement
Oct 31, 2026
Merged

SizzleUnrlsd merged 6 commits into
mainfrom
fix/285-block-placement

Conversation

@SizzleUnrlsd

@SizzleUnrlsd SizzleUnrlsd commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #285.

Whether a filter or an include of an extended template rendered depended only on the blocks it stood in, and on those the templates extending it replaced. It did not depend on where the parents place their blocks, so the walk was wrong both ways. Django 5.1.15 confirms each case:

Case Django applies Before
a block no template of the chain places (the issue) nothing the block's filters (false positive)
a block whose only placeholder a child replaces (wagtail's create.html and generic/form.html) nothing the block's filters (false positive)
a block a child redefines with {{ block.super }} inside a block it replaces (wagtail's generic/listing.html) the parent's definition, and what it includes nothing (false negative)

Each template's render is now put together over its extends chain, as Django puts it:

  • Placeholders: the root's blocks are placed. A placed block renders its most derived definition, then, through {{ block.super }}, the next ones up. The blocks a rendered definition holds are placed in turn.
  • Content: a filter or an include renders when the definition of the innermost block it stands in renders. Outside every block, it renders only in the root.
  • Unknown root: when the chain reaches no known root, every block of the chain counts as placed. This covers a parent named by an expression, a parent not found or found in several files, and an extends cycle. No filter is dropped on a placement that cannot be established, and the unread templates stay listed in Coverage.unread_templates.

What changes in the code.

The design and its measurements are in the issue.

Commit Change
refactor(templates): record the block each block of a template stands in _Template.enclosing, read with the blocks; nothing reads it yet
test(templates): pin what a chain keeps when its root is unknown, and its order what must not change; these tests pass before the fix
fix(templates): put each render together over its extends chain as Django places blocks the render over the chain; the three shapes and a three-level chain; the reference of the walk path by path
docs: state where a template's blocks render along its extends chain docs/usage.md
docs(changelog): announce the blocks placed along the extends chain CHANGELOG.md
fix(templates): place the blocks of a chain in time linear in its blocks the blocks each block holds indexed once per chain (review)

Results

Generated projects. 1,500 projects of four templates extending and including one another, inside and outside nested blocks, with cycles. Django 5.1.15 renders each template.

Templates Django renders Equal to what Django applies Missing something Applying more
without conditions, 1,262 before 1,159, after 1,262 before 0, after 0 before 103, after 0
includes under a condition ending the recursion, 3,272 before 2,306, after 2,632 before 0, after 0 before 966, after 640

Without conditions, the result is exactly what Django applies on every template it renders. With conditions, the walk follows every branch, so it applies more than one render does, but it misses nothing.

Unknown roots.

  • The prudent placement drops nothing: with every block of every chain placed, every name of every filter application found before is kept, on all 12,000 generated templates.
  • What changes: for templates whose chain reaches no known root, the filters of the chain's own templates keep their names in 3,020 of 3,032 cases. In the 12 others, the names lost only came through an include standing in a block that the included template's known root never places.

Fixpoint. On all 1,500 projects, the fixpoint closes, and the lists are the same whatever order the templates are inserted in: 4,500 runs out of 4,500.

Local corpus. On 53 projects, 52 are identical, order included. wagtail changes:

  • 36 filters gained: 9 listings include wagtailadmin/shared/header.html through generic/listing.html's header, which renders {{ block.super }}.
  • 10 filters lost: explorable_index.html replaces header, which held main_header. Six forms replace content without defining main_content, directly or through edit.html.

Each change was checked in the templates.

End to end. The reproduction of #285 reports nothing. Its control, where the parent places the block, still reports exploitable-vulnerability.

Time.

  • wagtail: 0.58 s instead of 0.71 s.
  • An extends chain of 200 templates, each adding to one block through {{ block.super }}: 0.12 s instead of 0.07 s.
  • The same with 1,000 templates: 6.3 s instead of 2.5 s, for the same 501,501 filters. The template rebuilds its chain each time; the chain's members are kept in a set.
  • One template of 16,000 empty blocks (501 KB, review): 0.08 s, as on main (0.07 s); the full analysis 0.32 s. Before the index of the blocks each block holds, more than 8 s, and 11.6 s.

Tests

New file. tests/test_template_block_placement.py holds 8 tests, 12 cases. Each expectation is what Django 5.1.15 applies:

  • Reproduction: the issue's case end to end, and its control.
  • Override: a placeholder removed by an override, and its control.
  • Nested block.super: a block redefined with {{ block.super }} inside a replaced block, and its control without it.
  • Multi-level chain: a chain of three templates, a block extended through two levels beside a block no template places.
  • Unknown root: a parent named by an expression or not found, and an extends cycle, keep their filters. The text outside the blocks never renders, and the unread templates stay listed.
  • Order: a chain lists its filters in the order of a walk through its extends.
  • Time: a template of 50,000 blocks is placed within 60 s, in a subprocess (0.35 s).

Existing test changed. One test changed, tests/test_template_filter_walk.py::test_the_filters_are_those_of_every_path_with_each_cycle_turned_until_it_settles. The changes were agreed in the review of the design.

  • Reference: it walked each path through extends with the former block rule. It now puts each render together over its chain, reading the blocks' nesting from the templates' text, apart from the engine, then follows the includes path by path as before.
  • Unverified projects: the budget (20,000 steps) and the bounds (2, then 3 turns) are unchanged. With this reference, 18 projects exceed the budget instead of 10, all of them verified as equal otherwise. So up to 20 may stay unverified, instead of 15.

Before the fix. On the parent commit, 5 tests fail for the expected reason:

  • the three shapes and the three-level chain give the former filters;
  • the comparison with the walk path by path finds other sets.

The controls, the unknown roots and the order pass, as they do on main.

Mutations. Each of these makes tests fail:

  • every block placed, known root or not;
  • {{ block.super }} never reaching the next definition;
  • the outermost block taken instead of the innermost;
  • a chain reaching no root taken as known;
  • the includes listed from the template up.

Verification

Python 3.12, on main at 8846ebb:

  • ruff check . and mypy: clean on each commit;
  • unit suite: 2,657, 2,661, 2,668, 2,668, 2,668 and 2,669 passed, commit after commit;
  • regression: 19 of 19, snapshots unchanged;
  • Enterprise plugins: 978 passed.

The template read for the filter walk keeps, for each block, the block it stands in,
None for one standing in none: what putting a render together over its extends chain
needs to place the blocks a definition holds. Nothing reads it yet.
… its order

A parent named by an expression or not found, and an extends cycle, keep every block
placed, and the text outside the blocks of a template extending another never renders;
the templates not found stay listed among the unread ones. A chain lists its filters in
the order of a walk through its extends: what it applies, then what it includes from
the root down.
…ango places blocks

Whether a filter or an include of an extended template rendered depended only on the
blocks it stood in and those the templates extending it replaced, not on where the
parents place their blocks (#285). A block no template of the chain places counted as
rendered, and so did a block whose only placeholder a child replaces; a block a child
redefines with {{ block.super }} inside a block it replaces did not render the parent's
definition, nor what it includes.

Each template's render is now put together over its extends chain: the root's blocks
are placed, a placed block renders its most derived definition and, through
{{ block.super }}, the next ones up, and the blocks a rendered definition holds are
placed in turn. A filter or an include renders when the definition of the innermost
block it stands in renders. When the chain reaches no known root, every block of the
chain counts as placed, so that no filter is dropped on a placement that cannot be
established. The fixpoint of #284 runs on the templates themselves, the pairs with the
blocks overridden being no longer needed.

The comparison with the walk path by path puts each render together over its chain,
reading the blocks' nesting from the text; with this reference, 18 generated projects
exceed the unchanged budget, so up to 20 may stay unverified.
@SizzleUnrlsd SizzleUnrlsd added the bug Something isn't working label Oct 27, 2026
@SizzleUnrlsd SizzleUnrlsd self-assigned this Oct 27, 2026
Each block placed looked up the blocks it holds among all the blocks of its template,
so placing a template's blocks took time quadratic in their number: a 501 KB template
of 16,000 empty blocks took more than 8 s to collect, where main takes 0.07 s, and the
full analysis 11.6 s instead of 0.5 s.

The blocks each block holds, and those standing in no block, are now indexed once per
chain, while the blocks are listed. The same 16,000 blocks are placed in 0.08 s, and
the full analysis takes 0.3 s. What is placed does not change.
@SizzleUnrlsd
SizzleUnrlsd merged commit 07adc12 into main Oct 31, 2026
10 checks passed
@SizzleUnrlsd
SizzleUnrlsd deleted the fix/285-block-placement branch October 31, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

templates: a block that no parent defines counts as rendered when collecting filters

1 participant