From 4d775cbe95703e262ff80230d91d8ada49313001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Thu, 14 May 2026 13:00:32 +0200 Subject: [PATCH] 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) --- tpl/partials/partials.go | 2 ++ tpl/template.go | 3 ++ tpl/templates/defer_integration_test.go | 45 +++++++++++++++++++++++++ tpl/templates/templates.go | 9 ++++- 4 files changed, 58 insertions(+), 1 deletion(-) 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") }