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>
This commit is contained in:
Bjørn Erik Pedersen
2026-05-14 13:00:32 +02:00
parent fdd977e95d
commit 4d775cbe95
4 changed files with 58 additions and 1 deletions
+2
View File
@@ -234,6 +234,8 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any
depsManagerShared = identity.NewManager() depsManagerShared = identity.NewManager()
ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, depsManagerShared.(identity.DependencyManagerScopedProvider)) 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) r := ns.doInclude(ctx, keyString, ti, context)
if ns.deps.Conf.Watching() { if ns.deps.Conf.Watching() {
r.mangager = depsManagerShared r.mangager = depsManagerShared
+3
View File
@@ -55,6 +55,7 @@ const (
contextKeyIsInGoldmark contextKeyIsInGoldmark
cntextKeyCurrentTemplateInfo cntextKeyCurrentTemplateInfo
contextKeyPartialDecoratorIDStack contextKeyPartialDecoratorIDStack
contextKeyIsInPartialCached
) )
// Context manages values passed in the context to templates. // Context manages values passed in the context to templates.
@@ -66,6 +67,7 @@ var Context = struct {
IsInGoldmark contexthelpers.ContextDispatcher[bool] IsInGoldmark contexthelpers.ContextDispatcher[bool]
CurrentTemplate contexthelpers.ContextDispatcher[*CurrentTemplateInfo] CurrentTemplate contexthelpers.ContextDispatcher[*CurrentTemplateInfo]
PartialDecoratorIDStack contexthelpers.ContextDispatcher[*collections.Stack[*StringBool]] PartialDecoratorIDStack contexthelpers.ContextDispatcher[*collections.Stack[*StringBool]]
IsInPartialCached contexthelpers.ContextDispatcher[bool]
}{ }{
DependencyManagerScopedProvider: contexthelpers.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKeyDependencyManagerScopedProvider), DependencyManagerScopedProvider: contexthelpers.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKeyDependencyManagerScopedProvider),
DependencyScope: contexthelpers.NewContextDispatcher[int](contextKeyDependencyScope), DependencyScope: contexthelpers.NewContextDispatcher[int](contextKeyDependencyScope),
@@ -73,6 +75,7 @@ var Context = struct {
IsInGoldmark: contexthelpers.NewContextDispatcher[bool](contextKeyIsInGoldmark), IsInGoldmark: contexthelpers.NewContextDispatcher[bool](contextKeyIsInGoldmark),
CurrentTemplate: contexthelpers.NewContextDispatcher[*CurrentTemplateInfo](cntextKeyCurrentTemplateInfo), CurrentTemplate: contexthelpers.NewContextDispatcher[*CurrentTemplateInfo](cntextKeyCurrentTemplateInfo),
PartialDecoratorIDStack: contexthelpers.NewContextDispatcher[*collections.Stack[*StringBool]](contextKeyPartialDecoratorIDStack), PartialDecoratorIDStack: contexthelpers.NewContextDispatcher[*collections.Stack[*StringBool]](contextKeyPartialDecoratorIDStack),
IsInPartialCached: contexthelpers.NewContextDispatcher[bool](contextKeyIsInPartialCached),
} }
func init() { func init() {
+45
View File
@@ -322,3 +322,48 @@ End.
b.AssertFileContent("public/index.html", "Home.", "Defer 1", "Defer 2", "Defer 3", "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")
}
+8 -1
View File
@@ -53,11 +53,18 @@ func (ns *Namespace) Exists(name string) bool {
} }
// Defer defers the execution of a template block. // 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, // Prevent defer from being used in content adapters,
// that just doesn't work. // that just doesn't work.
ns.deps.Site.CheckReady() 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 { if len(args) != 0 {
return false, fmt.Errorf("Defer does not take any arguments") return false, fmt.Errorf("Defer does not take any arguments")
} }