Repository: gohugoio/hugo (language: go)

Issue:
server/template.Defer: panic: html/template: cannot Clone "" after it has executed
I thought I had fixed this, but working on a new JS feature I'm using `template.Defer` extensively and get the error above on some rebuilds. Note that this is not an issue for regular builds.


Discussion / hints:
OK; I think I finally understand how this happens, which is a great start:

1. Start server in fast render mode (default) and load home page. Edit `baseof.html` template, home rebuilds OK.
2. Navigate to some other page that uses that same `baseof.html` template ... boom.

Note that since we fixed the issue with "stuck server errors", this issue isn't as painful as it was, as one more edit will get you out of the error state.



Fix patch:
diff --git a/common/types/evictingqueue.go b/common/types/evictingqueue.go
index 88add59d539..5ab715aa871 100644
--- a/common/types/evictingqueue.go
+++ b/common/types/evictingqueue.go
@@ -65,6 +65,9 @@ func (q *EvictingStringQueue) Len() int {
 
 // Contains returns whether the queue contains v.
 func (q *EvictingStringQueue) Contains(v string) bool {
+	if q == nil {
+		return false
+	}
 	q.mu.Lock()
 	defer q.mu.Unlock()
 	return q.set[v]
diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go
index 9e2af046dde..0a593593b88 100644
--- a/tpl/tplimpl/template.go
+++ b/tpl/tplimpl/template.go
@@ -30,6 +30,7 @@ import (
 	"unicode"
 	"unicode/utf8"
 
+	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/output/layouts"
 
@@ -191,8 +192,10 @@ func newTemplateHandlers(d *deps.Deps) (*tpl.TemplateHandlers, error) {
 
 func newTemplateNamespace(funcs map[string]any) *templateNamespace {
 	return &templateNamespace{
-		prototypeHTML: htmltemplate.New("").Funcs(funcs),
-		prototypeText: texttemplate.New("").Funcs(funcs),
+		prototypeHTML:           htmltemplate.New("").Funcs(funcs),
+		prototypeText:           texttemplate.New("").Funcs(funcs),
+		prototypeHTMLCloneCache: maps.NewCache[prototypeCloneID, *htmltemplate.Template](),
+		prototypeTextCloneCache: maps.NewCache[prototypeCloneID, *texttemplate.Template](),
 		templateStateMap: &templateStateMap{
 			templates: make(map[string]*templateState),
 		},
@@ -688,7 +691,7 @@ func (t *templateHandler) addTemplateTo(info templateInfo, to *templateNamespace
 func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Template, error) {
 	if overlay.isText {
 		var (
-			templ = t.main.prototypeTextClone.New(overlay.name)
+			templ = t.main.getPrototypeText(prototypeCloneIDBaseof).New(overlay.name)
 			err   error
 		)
 
@@ -713,7 +716,7 @@ func (t *templateHandler) applyBaseTemplate(overlay, base templateInfo) (tpl.Tem
 	}
 
 	var (
-		templ = t.main.prototypeHTMLClone.New(overlay.name)
+		templ = t.main.getPrototypeHTML(prototypeCloneIDBaseof).New(overlay.name)
 		err   error
 	)
 
@@ -953,27 +956,37 @@ func (t *templateHandler) postTransform() error {
 	return nil
 }
 
+type prototypeCloneID uint16
+
+const (
+	prototypeCloneIDBaseof prototypeCloneID = iota + 1
+	prototypeCloneIDDefer
+)
+
 type templateNamespace struct {
-	prototypeText      *texttemplate.Template
-	prototypeHTML      *htmltemplate.Template
-	prototypeTextClone *texttemplate.Template
-	prototypeHTMLClone *htmltemplate.Template
+	prototypeText *texttemplate.Template
+	prototypeHTML *htmltemplate.Template
+
+	prototypeHTMLCloneCache *maps.Cache[prototypeCloneID, *htmltemplate.Template]
+	prototypeTextCloneCache *maps.Cache[prototypeCloneID, *texttemplate.Template]
 
 	*templateStateMap
 }
 
-func (t *templateNamespace) getPrototypeText() *texttemplate.Template {
-	if t.prototypeTextClone != nil {
-		return t.prototypeTextClone
+func (t *templateNamespace) getPrototypeText(id prototypeCloneID) *texttemplate.Template {
+	v, ok := t.prototypeTextCloneCache.Get(id)
+	if !ok {
+		return t.prototypeText
 	}
-	return t.prototypeText
+	return v
 }
 
-func (t *templateNamespace) getPrototypeHTML() *htmltemplate.Template {
-	if t.prototypeHTMLClone != nil {
-		return t.prototypeHTMLClone
+func (t *templateNamespace) getPrototypeHTML(id prototypeCloneID) *htmltemplate.Template {
+	v, ok := t.prototypeHTMLCloneCache.Get(id)
+	if !ok {
+		return t.prototypeHTML
 	}
-	return t.prototypeHTML
+	return v
 }
 
 func (t *templateNamespace) Lookup(name string) (tpl.Template, bool) {
@@ -989,9 +1002,10 @@ func (t *templateNamespace) Lookup(name string) (tpl.Template, bool) {
 }
 
 func (t *templateNamespace) createPrototypes() error {
-	t.prototypeTextClone = texttemplate.Must(t.prototypeText.Clone())
-	t.prototypeHTMLClone = htmltemplate.Must(t.prototypeHTML.Clone())
-
+	for _, id := range []prototypeCloneID{prototypeCloneIDBaseof, prototypeCloneIDDefer} {
+		t.prototypeHTMLCloneCache.Set(id, htmltemplate.Must(t.prototypeHTML.Clone()))
+		t.prototypeTextCloneCache.Set(id, texttemplate.Must(t.prototypeText.Clone()))
+	}
 	return nil
 }
 
@@ -1021,7 +1035,7 @@ func (t *templateNamespace) addDeferredTemplate(owner *templateState, name strin
 	var templ tpl.Template
 
 	if owner.isText() {
-		prototype := t.getPrototypeText()
+		prototype := t.getPrototypeText(prototypeCloneIDDefer)
 		tt, err := prototype.New(name).Parse("")
 		if err != nil {
 			return fmt.Errorf("failed to parse empty text template %q: %w", name, err)
@@ -1029,7 +1043,7 @@ func (t *templateNamespace) addDeferredTemplate(owner *templateState, name strin
 		tt.Tree.Root = n
 		templ = tt
 	} else {
-		prototype := t.getPrototypeHTML()
+		prototype := t.getPrototypeHTML(prototypeCloneIDDefer)
 		tt, err := prototype.New(name).Parse("")
 		if err != nil {
 			return fmt.Errorf("failed to parse empty HTML template %q: %w", name, err)


Test patch:
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index b593cd96577..7a6c040b16a 100644
--- a/hugolib/integrationtest_builder.go
+++ b/hugolib/integrationtest_builder.go
@@ -25,6 +25,7 @@ import (
 	"github.com/gohugoio/hugo/common/hexec"
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
+	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/config/allconfig"
 	"github.com/gohugoio/hugo/config/security"
@@ -466,6 +467,28 @@ func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
 	return s
 }
 
+func (s *IntegrationTestBuilder) BuildPartial(urls ...string) *IntegrationTestBuilder {
+	if _, err := s.BuildPartialE(urls...); err != nil {
+		s.Fatal(err)
+	}
+	return s
+}
+
+func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTestBuilder, error) {
+	if s.buildCount == 0 {
+		panic("BuildPartial can only be used after a full build")
+	}
+	if !s.Cfg.Running {
+		panic("BuildPartial can only be used in server mode")
+	}
+	visited := types.NewEvictingStringQueue(len(urls))
+	for _, url := range urls {
+		visited.Add(url)
+	}
+	buildCfg := BuildCfg{RecentlyVisited: visited, PartialReRender: true}
+	return s, s.build(buildCfg)
+}
+
 func (s *IntegrationTestBuilder) Close() {
 	s.Helper()
 	s.Assert(s.H.Close(), qt.IsNil)
@@ -747,10 +770,6 @@ func (s *IntegrationTestBuilder) build(cfg BuildCfg) error {
 	s.counters = &buildCounters{}
 	cfg.testCounters = s.counters
 
-	if s.buildCount > 0 && (len(changeEvents) == 0) {
-		return nil
-	}
-
 	s.buildCount++
 
 	err := s.H.Build(cfg, changeEvents...)
diff --git a/internal/js/esbuild/batch_integration_test.go b/internal/js/esbuild/batch_integration_test.go
index 55528bdf042..b4a2454acf3 100644
--- a/internal/js/esbuild/batch_integration_test.go
+++ b/internal/js/esbuild/batch_integration_test.go
@@ -721,43 +721,3 @@ console.log("config.params.id", id3);
 	b.EditFileReplaceAll("assets/other/bar.css", ".bar-edit {", ".bar-edit2 {").Build()
 	b.AssertFileContent("public/mybundle/reactbatch.css", ".bar-edit2 {")
 }
-
-func TestEditBaseofManyTimes(t *testing.T) {
-	files := `
--- hugo.toml --
-baseURL = "https://example.com"
-disableLiveReload = true
-disableKinds = ["taxonomy", "term"]
--- layouts/_default/baseof.html --
-Baseof.
-{{ block "main" . }}{{ end }}
-{{ with (templates.Defer (dict "key" "global")) }}
-Now. {{ now }}
-{{ end }}
--- layouts/_default/single.html --
-{{ define "main" }}
-Single.
-{{ end }}
---
--- layouts/_default/list.html --
-{{ define "main" }}
-List.
-{{ end }}
--- content/mybundle/index.md --
----
-title: "My Bundle"
----
--- content/_index.md --
----
-title: "Home"
----
-`
-
-	b := hugolib.TestRunning(t, files)
-	b.AssertFileContent("public/index.html", "Baseof.")
-
-	for i := 0; i < 100; i++ {
-		b.EditFileReplaceAll("layouts/_default/baseof.html", "Now", "Now.").Build()
-		b.AssertFileContent("public/index.html", "Now..")
-	}
-}
diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go
index 1e7aa311132..dbadece4e52 100644
--- a/tpl/tplimpl/tplimpl_integration_test.go
+++ b/tpl/tplimpl/tplimpl_integration_test.go
@@ -649,3 +649,52 @@ E: An _emphasized_ word.
 		"<details>\n  <summary>Details</summary>\n  <p>D: An <em>emphasized</em> word.</p>\n</details>",
 	)
 }
+
+// Issue 12963
+func TestEditBaseofParseAfterExecute(t *testing.T) {
+	files := `
+-- hugo.toml --
+baseURL = "https://example.com"
+disableLiveReload = true
+disableKinds = ["taxonomy", "term", "rss", "404", "sitemap"]
+[internal]
+fastRenderMode = true
+-- layouts/_default/baseof.html --
+Baseof!
+{{ block "main" . }}default{{ end }}
+{{ with (templates.Defer (dict "key" "global")) }}
+Now. {{ now }}
+{{ end }}
+-- layouts/_default/single.html --
+{{ define "main" }}
+Single.
+{{ end }}
+-- layouts/_default/list.html --
+{{ define "main" }}
+List.
+{{ .Content }}
+{{ range .Pages }}{{ .Title }}{{ end }}|
+{{ end }}
+-- content/mybundle1/index.md --
+---
+title: "My Bundle 1"
+---
+-- content/mybundle2/index.md --
+---
+title: "My Bundle 2"
+---
+-- content/_index.md --
+---
+title: "Home"
+---
+Home!
+`
+
+	b := hugolib.TestRunning(t, files)
+	b.AssertFileContent("public/index.html", "Home!")
+	b.EditFileReplaceAll("layouts/_default/baseof.html", "Baseof", "Baseof!").Build()
+	b.BuildPartial("/")
+	b.AssertFileContent("public/index.html", "Baseof!!")
+	b.BuildPartial("/mybundle1/")
+	b.AssertFileContent("public/mybundle1/index.html", "Baseof!!")
+}