Files
hugo/tpl/templates/defer_integration_test.go
T
Bjørn Erik Pedersen 4d775cbe95 tpl/templates: Reject Defer inside partialCached
A partial's rendered output (placeholder included) is cached by
partialCached across rebuilds, but BuildState.DeferredExecutions
is reset every stage. On a fast-render rebuild the cached string
replays the placeholder while doDefer is not called this build,
leaving executeDeferredTemplates to panic with "deferred execution
with id ... not found".

Mark the ctx inside IncludeCached's body execution and have Defer
return a clear error if it sees the flag. Catches transitive cases
(partialCached -> partial -> Defer) via ctx propagation. Defer in
baseof.html and in a plain partial is unaffected.

Fixes #13492

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:17:42 +02:00

370 lines
8.7 KiB
Go

// Copyright 2024 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 (
"fmt"
"path/filepath"
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
const deferFilesCommon = `
-- hugo.toml --
disableLiveReload = true
disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404", "section"]
[languages]
[languages.en]
weight = 1
[languages.nn]
weight = 2
-- i18n/en.toml --
[hello]
other = "Hello"
-- i18n/nn.toml --
[hello]
other = "Hei"
-- content/_index.en.md --
---
title: "Home"
outputs: ["html", "amp"]
---
-- content/_index.nn.md --
---
title: "Heim"
outputs: ["html", "amp"]
---
-- assets/mytext.txt --
Hello.
-- layouts/baseof.html --
HTML|{{ block "main" . }}{{ end }}$
-- layouts/home.html --
{{ define "main" }}
EDIT_COUNTER_OUTSIDE_0
{{ .Store.Set "hello" "Hello" }}
{{ $data := dict "page" . }}
{{ with (templates.Defer (dict "data" $data) ) }}
{{ $mytext := resources.Get "mytext.txt" }}
REPLACE_ME|Title: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}|Hello Store: {{ .page.Store.Get "hello" }}|Mytext: {{ $mytext.Content }}|
EDIT_COUNTER_DEFER_0
{{ end }}$
{{ end }}
-- layouts/index.amp.html --
AMP.
{{ $data := dict "page" . }}
{{ with (templates.Defer (dict "data" $data) ) }}Title AMP: {{ .page.Title }}|{{ .page.RelPermalink }}|Hello: {{ T "hello" }}{{ end }}$
`
func TestDeferNoBaseof(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/home.html --
Home.
{{ with (templates.Defer (dict "key" "foo")) }}
Defer
{{ end }}
-- content/_index.md --
---
title: "Home"
---
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "Home.\n\n Defer")
}
func TestDeferBaseof(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/baseof.html --
{{ with (templates.Defer (dict "key" "foo")) }}
Defer
{{ end }}
Block:{{ block "main" . }}{{ end }}$
-- layouts/home.html --
{{ define "main" }}
Home.
{{ end }}
-- content/_index.md --
---
title: "Home"
---
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "Home.\n\n Defer")
}
func TestDeferMain(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/baseof.html --
Block:{{ block "main" . }}{{ end }}$
-- layouts/home.html --
{{ define "main" }}
Home.
{{ with (templates.Defer (dict "key" "foo")) }}
Defer
{{ end }}
{{ end }}
-- content/_index.md --
---
title: "Home"
---
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "Home.\n\n Defer")
}
func TestDeferBasic(t *testing.T) {
t.Parallel()
b := hugolib.Test(t, deferFilesCommon)
b.AssertFileContent("public/index.html", "Title: Home|/|Hello: Hello|Hello Store: Hello|Mytext: Hello.|")
b.AssertFileContent("public/amp/index.html", "Title AMP: Home|/amp/|Hello: Hello")
b.AssertFileContent("public/nn/index.html", "Title: Heim|/nn/|Hello: Hei")
b.AssertFileContent("public/nn/amp/index.html", "Title AMP: Heim|/nn/amp/|Hello: Hei")
}
func TestDeferRepeatedBuildsEditOutside(t *testing.T) {
t.Parallel()
b := hugolib.TestRunning(t, deferFilesCommon)
for i := range 5 {
old := fmt.Sprintf("EDIT_COUNTER_OUTSIDE_%d", i)
new := fmt.Sprintf("EDIT_COUNTER_OUTSIDE_%d", i+1)
b.EditFileReplaceAll("layouts/home.html", old, new).Build()
b.AssertFileContent("public/index.html", new)
}
}
func TestDeferRepeatedBuildsEditDefer(t *testing.T) {
t.Parallel()
b := hugolib.TestRunning(t, deferFilesCommon)
for i := range 8 {
old := fmt.Sprintf("EDIT_COUNTER_DEFER_%d", i)
new := fmt.Sprintf("EDIT_COUNTER_DEFER_%d", i+1)
b.EditFileReplaceAll("layouts/home.html", old, new).Build()
b.AssertFileContent("public/index.html", new)
}
}
func TestDeferErrorParse(t *testing.T) {
t.Parallel()
b, err := hugolib.TestE(t, strings.ReplaceAll(deferFilesCommon, "Title AMP: {{ .page.Title }}", "{{ .page.Title }"))
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, `index.amp.html:3: unexpected "}" in operand`)
}
func TestDeferErrorRuntime(t *testing.T) {
t.Parallel()
b, err := hugolib.TestE(t, strings.ReplaceAll(deferFilesCommon, "Title AMP: {{ .page.Title }}", "{{ .page.Titles }}"))
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, filepath.FromSlash(`/layouts/index.amp.html:3:57`))
b.Assert(err.Error(), qt.Contains, `execute of template failed: template: index.amp.html:3:57: executing at <.page.Titles>: can't evaluate field Titles`)
}
func TestDeferEditDeferBlock(t *testing.T) {
t.Parallel()
b := hugolib.TestRunning(t, deferFilesCommon)
b.AssertRenderCountPage(4)
b.EditFileReplaceAll("layouts/home.html", "REPLACE_ME", "Edited.").Build()
b.AssertFileContent("public/index.html", "Edited.")
b.AssertRenderCountPage(2)
}
//
func TestDeferEditResourceUsedInDeferBlock(t *testing.T) {
t.Parallel()
b := hugolib.TestRunning(t, deferFilesCommon)
b.AssertRenderCountPage(4)
b.EditFiles("assets/mytext.txt", "Mytext Hello Edited.").Build()
b.AssertFileContent("public/index.html", "Mytext Hello Edited.")
b.AssertRenderCountPage(2)
}
func TestDeferMountPublic(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
[module]
[[module.mounts]]
source = "content"
target = "content"
[[module.mounts]]
source = "layouts"
target = "layouts"
[[module.mounts]]
source = 'public'
target = 'assets/public'
disableWatch = true
-- layouts/home.html --
Home.
{{ $mydata := dict "v1" "v1value" }}
{{ $json := resources.FromString "mydata/data.json" ($mydata | jsonify ) }}
{{ $nop := $json.RelPermalink }}
{{ with (templates.Defer (dict "key" "foo")) }}
{{ $jsonFilePublic := resources.Get "public/mydata/data.json" }}
{{ with $jsonFilePublic }}
{{ $m := $jsonFilePublic | transform.Unmarshal }}
v1: {{ $m.v1 }}
{{ end }}
{{ end }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "v1: v1value")
}
func TestDeferFromContentAdapterShouldFail(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- content/_content.gotmpl --
{{ with (templates.Defer (dict "key" "foo")) }}
Foo.
{{ end }}
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "error calling Defer: this method cannot be called before the site is fully initialized")
}
func TestDeferPostProcessShouldThrowAnError(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- assets/mytext.txt --
ABCD.
-- layouts/home.html --
Home
{{ with (templates.Defer (dict "key" "foo")) }}
{{ $mytext := resources.Get "mytext.txt" | minify | resources.PostProcess }}
{{ end }}
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "resources.PostProcess cannot be used in a deferred template")
}
// Issue #13236.
func TestDeferMultipleInSameTemplate(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/home.html --
Home.
...
{{ with (templates.Defer (dict "data" (dict "a" "b") )) }}
Defer 1
{{ end }}
...
{{ with (templates.Defer (dict "data" (dict "a" "c") )) }}
Defer 2
{{ end }}
{{ with (templates.Defer (dict "data" (dict "a" "d") )) }}
Defer 3
{{ end }}{{ with (templates.Defer (dict "data" (dict "a" "d") )) }}{{ end }}
End.
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "Home.", "Defer 1", "Defer 2", "Defer 3", "End.")
}
// See issue 13492.
func TestDeferInsidePartialCachedShouldFail(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/home.html --
Home.
{{ partialCached "css.html" . }}
-- layouts/partials/css.html --
{{ with (templates.Defer (dict "key" "global")) }}
Defer
{{ end }}
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "templates.Defer cannot be used inside a partialCached partial")
}
// Verifies that the partialCached ctx flag propagates through a regular partial call.
// See issue 13492.
func TestDeferTransitivelyInsidePartialCachedShouldFail(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/home.html --
Home.
{{ partialCached "outer.html" . }}
-- layouts/partials/outer.html --
{{ partial "inner.html" . }}
-- layouts/partials/inner.html --
{{ with (templates.Defer (dict "key" "global")) }}
Defer
{{ end }}
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "templates.Defer cannot be used inside a partialCached partial")
}