diff --git a/tpl/partials/partials.go b/tpl/partials/partials.go index 58899fb77..be552ee30 100644 --- a/tpl/partials/partials.go +++ b/tpl/partials/partials.go @@ -234,6 +234,8 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any depsManagerShared = identity.NewManager() ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, depsManagerShared.(identity.DependencyManagerScopedProvider)) } + // Mark the ctx so templates.Defer can reject being called from a cached body. + ctx = tpl.Context.IsInPartialCached.Set(ctx, true) r := ns.doInclude(ctx, keyString, ti, context) if ns.deps.Conf.Watching() { r.mangager = depsManagerShared diff --git a/tpl/template.go b/tpl/template.go index 848736267..1708adc75 100644 --- a/tpl/template.go +++ b/tpl/template.go @@ -55,6 +55,7 @@ const ( contextKeyIsInGoldmark cntextKeyCurrentTemplateInfo contextKeyPartialDecoratorIDStack + contextKeyIsInPartialCached ) // Context manages values passed in the context to templates. @@ -66,6 +67,7 @@ var Context = struct { IsInGoldmark contexthelpers.ContextDispatcher[bool] CurrentTemplate contexthelpers.ContextDispatcher[*CurrentTemplateInfo] PartialDecoratorIDStack contexthelpers.ContextDispatcher[*collections.Stack[*StringBool]] + IsInPartialCached contexthelpers.ContextDispatcher[bool] }{ DependencyManagerScopedProvider: contexthelpers.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKeyDependencyManagerScopedProvider), DependencyScope: contexthelpers.NewContextDispatcher[int](contextKeyDependencyScope), @@ -73,6 +75,7 @@ var Context = struct { IsInGoldmark: contexthelpers.NewContextDispatcher[bool](contextKeyIsInGoldmark), CurrentTemplate: contexthelpers.NewContextDispatcher[*CurrentTemplateInfo](cntextKeyCurrentTemplateInfo), PartialDecoratorIDStack: contexthelpers.NewContextDispatcher[*collections.Stack[*StringBool]](contextKeyPartialDecoratorIDStack), + IsInPartialCached: contexthelpers.NewContextDispatcher[bool](contextKeyIsInPartialCached), } func init() { diff --git a/tpl/templates/defer_integration_test.go b/tpl/templates/defer_integration_test.go index f876b0b7e..4f67898bc 100644 --- a/tpl/templates/defer_integration_test.go +++ b/tpl/templates/defer_integration_test.go @@ -322,3 +322,48 @@ End. 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") +} diff --git a/tpl/templates/templates.go b/tpl/templates/templates.go index 8428ff252..ad8e08d92 100644 --- a/tpl/templates/templates.go +++ b/tpl/templates/templates.go @@ -53,11 +53,18 @@ func (ns *Namespace) Exists(name string) bool { } // Defer defers the execution of a template block. -func (ns *Namespace) Defer(args ...any) (bool, error) { +func (ns *Namespace) Defer(ctx context.Context, args ...any) (bool, error) { // Prevent defer from being used in content adapters, // that just doesn't work. ns.deps.Site.CheckReady() + // Reject use inside a partialCached body: the cached output retains the + // deferred placeholder across rebuilds while the executions map is reset, + // which used to cause a panic in executeDeferredTemplates. See issue #13492. + if tpl.Context.IsInPartialCached.Get(ctx) { + return false, fmt.Errorf("templates.Defer cannot be used inside a partialCached partial; use partial instead, or move templates.Defer to the calling template") + } + if len(args) != 0 { return false, fmt.Errorf("Defer does not take any arguments") }