From f472dd48a7dc3ae0304871eef785b00b9ead8160 Mon Sep 17 00:00:00 2001 From: Simon Heimlicher Date: Sat, 24 Jan 2026 16:59:18 +0100 Subject: [PATCH] tpl: Fix partial decorator panic when partial returns falsy When {{ with partial "foo" }} returns a falsy value (false, nil, ""), the with block is skipped but _popPartialDecorator was only called inside the with block. This left an orphan entry on the decorator stack, causing "partial decorator ID mismatch" panic on subsequent partial calls. Add _popPartialDecorator call to the else branch to ensure the decorator stack is always balanced regardless of the partial's return value. Fixes #14419 --- tpl/templates/decorator_falsy_test.go | 63 +++++++++++++++++++++++++++ tpl/tplimpl/templatetransform.go | 36 +++++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 tpl/templates/decorator_falsy_test.go diff --git a/tpl/templates/decorator_falsy_test.go b/tpl/templates/decorator_falsy_test.go new file mode 100644 index 000000000..ede758a0b --- /dev/null +++ b/tpl/templates/decorator_falsy_test.go @@ -0,0 +1,63 @@ +// Copyright 2025 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package templates_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// TestDecoratorPartialFalsyReturn tests that partial decorators work correctly +// when a partial returns a falsy value (false, nil, ""). This was a bug in +// v0.154.0-v0.154.5 where the decorator stack would become unbalanced. +// See https://github.com/gohugoio/hugo/issues/14419 +func TestDecoratorPartialFalsyReturn(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"] +-- content/p1.md -- +--- +title: "Page 1" +--- +-- layouts/_partials/a.html -- +{{ $result := dict }} +{{ with partialCached "b.html" . .RelPermalink }} + {{ $result = . }} +{{ end }} +{{ return $result }} +-- layouts/_partials/b.html -- +{{ $result := dict }} +{{ with partialCached "c.html" . "key1" }} + {{ $result = merge $result (dict "c" .) }} +{{ end }} +{{ with partialCached "d.html" . "key2" }} + {{ $result = merge $result (dict "d" .) }} +{{ end }} +{{ return $result }} +-- layouts/_partials/c.html -- +{{ return false }} +-- layouts/_partials/d.html -- +{{ return "truthy" }} +-- layouts/home.html -- +{{ range site.RegularPages }} +{{ with partialCached "a.html" (dict "Page" . "RelPermalink" .RelPermalink) .RelPermalink }}d:{{ .d }}{{ end }} +{{ end }}$ +` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", "d:truthy", "$") +} diff --git a/tpl/tplimpl/templatetransform.go b/tpl/tplimpl/templatetransform.go index ffb859b3e..bab71feb8 100644 --- a/tpl/tplimpl/templatetransform.go +++ b/tpl/tplimpl/templatetransform.go @@ -110,13 +110,18 @@ const ( // _pushPartialDecorator is always falsy. pushPartialDecoratorTempl = `{{ if or (_pushPartialDecorator ("PLACEHOLDER")) }}{{ end }}` popPartialDecoratorTempl = `{{ if (_popPartialDecorator ("PLACEHOLDER1")) }}{{ . }}{{ else }}("PLACEHOLDER2"){{ end }}` + + // popPartialDecoratorElseTempl is used when partial returns falsy and the with block is skipped. + // We still need to pop the decorator ID from the stack. + popPartialDecoratorElseTempl = `{{ _popPartialDecorator ("PLACEHOLDER") }}` ) var ( - partialReturnWrapper *parse.ListNode - doDefer *parse.ListNode - popPartialDecorator *parse.ListNode - pushPartialDecorator *parse.ListNode + partialReturnWrapper *parse.ListNode + doDefer *parse.ListNode + popPartialDecorator *parse.ListNode + pushPartialDecorator *parse.ListNode + popPartialDecoratorElse *parse.ListNode ) func init() { @@ -143,6 +148,12 @@ func init() { panic(err) } pushPartialDecorator = templ.Tree.Root + + templ, err = texttemplate.New("").Funcs(texttemplate.FuncMap{"_popPartialDecorator": func(string) string { return "" }}).Parse(popPartialDecoratorElseTempl) + if err != nil { + panic(err) + } + popPartialDecoratorElse = templ.Tree.Root } // wrapInPartialReturnWrapper copies and modifies the parsed nodes of a @@ -376,6 +387,23 @@ func (c *templateTransformContext) handleWithPartial(withNode *parse.WithNode) { withNode.Pipe.Cmds = append(orNode.Pipe.Cmds, withNode.Pipe.Cmds...) withNode.List = newInner + + // When the partial returns a falsy value, the with block is skipped, + // but we still need to pop the decorator ID from the stack. + // Add a pop call to the else branch. + popElse := popPartialDecoratorElse.CopyList() + popElseAction := popElse.Nodes[0].(*parse.ActionNode) + popElsePipe := popElseAction.Pipe.Cmds[0].Args[1].(*parse.PipeNode) + popElseString := popElsePipe.Cmds[0].Args[0].(*parse.StringNode) + popElseString.Text = innerHash + popElseString.Quoted = fmt.Sprintf("%q", popElseString.Text) + + if withNode.ElseList == nil { + withNode.ElseList = popElse + } else { + // Prepend the pop to the existing else list + withNode.ElseList.Nodes = append(popElse.Nodes, withNode.ElseList.Nodes...) + } } func (c *templateTransformContext) handleWith(withNode *parse.WithNode) { -- 2.39.5