Skip to content

fix(pug-linker): render nested block append/prepend once - #3480

Open
seebaermichi wants to merge 5 commits into
pugjs:masterfrom
seebaermichi:fix-3158-nested-blocks
Open

seebaermichi wants to merge 5 commits into
pugjs:masterfrom
seebaermichi:fix-3158-nested-blocks

Conversation

@seebaermichi

Copy link
Copy Markdown

Fixes #3158. This picks up #3475 by @afonsojanu, which its author closed unmerged. It uses a different approach, because #3475 drops content in a few cases (details below).

The bug. In a template that extends a layout, a block append / prepend / replace that is nested inside another block is merged into the layout's block, but it also stays where it was written. So its content renders twice. For example, with a layout that has block head inside head and block content inside body, a page that writes block append head with script(src="page.js") inside its block content gets page.js in head and a second time in body. On pug@3.0.4 and on master (c323ed3e) it renders <html><head><script src="layout.js"></script><script src="page.js"></script></head><body><p>Page content</p><script src="page.js"></script></body></html>. The fixture issue-3158.nested-append.pug is exactly this case.

The same happens when the nested block comes from an include (the case in #3158), sits in a mixin definition, an each or another nested append, targets a block the layout declares twice, or is three extends levels deep.

Cause. extend in pug-linker walks the whole child AST, not only its top level, so nested blocks are merged too. Nothing removes them from where they were written.

The fix. A new removeMergedBlocks pass empties a named block in place when a block it was merged into is still part of the tree. When every block it was merged into has been replaced away (for example a page that replaces block content and declares block sidebar again inside it, while the layout's sidebar sat inside the old content), the copy in place is the only one left, so it is kept. The pass runs once, on the fully linked tree, because a block that is still there at a middle extends level can be replaced away by a template further down. An included file that extends a layout gets the same pass before its blocks are unwrapped into plain blocks, because nothing can extend them after that.

Why not #3475's approach. #3475 empties every merged block while extending, also when the block it was merged into is replaced later. That loses content. With its linker, a block redeclared or appended inside a replaced parent block disappears entirely, where master renders it once. It also still renders twice when a grandchild template appends to or replaces a block that the middle template nests, because the grandchild's extend refills the emptied block. The existing fixture cases render identically with #3475, because none of them covers these.

Output change. A nested replace (block head written inside block content) used to render the new content both in the layout's head and in place. It now renders once, in head. A template that relied on the double render will change. Unchanged: merging stays static, so a nested append inside if false or an uncalled mixin still reaches its target, now once.

Tests. 19 fixture cases in packages/pug/test/cases/issue-3158.*.pug, plus their templates in auxiliary/:

  • 13 cover the double render. They fail on master and pass with this change.
  • 6 cover blocks whose target was replaced away. They pass on master too and pin that their content is kept. With fix(linker): stop appended/prepended blocks rendering twice when nested #3475's linker, those 6 and the two three-level cases fail.
  • The pug-linker snapshots are unchanged (24/24).
  • The rest of the suite shows only the two V8 error-wording failures that master has.
  • prettier --list-different is clean on pug-linker/index.js.
  • Nera's test suites and site builds against this branch are green, with byte-identical output.

Happy to cut the fixtures down to a handful if 19 is too many.

Michael Becker added 5 commits November 6, 2026 10:27
A block append/prepend/replace nested inside another block of an
extending template was merged into the layout's block and also kept in
place, so its content rendered twice (pugjs#3158). Empty the block in place
when a block it was merged into is still part of the tree; keep it when
all of those were replaced away.
Add regression cases for a nested replace, an appending include (once
and twice), a nested append inside a mixin, `each` and another nested
append, a block the layout declares twice, three-level chains, and the
guard cases where the parent block is replaced away (the nested block
must still render once, in place).
…inked

Emptying a merged block at every `extends` level lost its content when a
template further down replaced an ancestor of the block it had been
merged into: the copy in place was already gone. Run the pass once, on
the fully linked tree, so only blocks that still have a rendered target
are emptied.
… later

The page replaces the layout block that holds the append's target, one extends level above the template that nests the append. The append renders once where it was written, as on master.
…s a layout

An included file that extends a layout has its named blocks turned into plain blocks before the includer is linked, so the final pass no longer saw them and a block append nested in that file rendered twice again. The included tree is final at that point, so run the pass on it before unwrapping its blocks.
@rollingversions

Copy link
Copy Markdown

There is no change log for this pull request yet.

Create a changelog

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Append in included file causes append to be printed twice

1 participant