From 3c980c072ee6a9c37a1c6028a7d328696f745836 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Sun, 15 Mar 2026 11:38:18 +0100 Subject: [PATCH] resources: Re-publish on transformation cache hit When a resource transformation result was served from cache (same options as a previous build), the output file was not re-written to disk. This caused incorrect output when toggling transformation options (e.g. minify) back to a previously seen value in server mode. Fixes #14629 --- deps/deps.go | 8 +++++++ hugolib/hugo_sites.go | 6 ++--- hugolib/hugo_sites_build.go | 2 +- hugolib/page.go | 2 +- hugolib/site_render.go | 2 +- identity/identity.go | 5 +++++ resources/resource_cache.go | 6 +++++ resources/resource_spec.go | 9 ++++++-- resources/transform.go | 37 +++++++++++++++++++++++++++++++ tpl/css/build_integration_test.go | 37 +++++++++++++++++++++++++++++++ 10 files changed, 105 insertions(+), 9 deletions(-) diff --git a/deps/deps.go b/deps/deps.go index 85e808527..81dacab2e 100644 --- a/deps/deps.go +++ b/deps/deps.go @@ -441,6 +441,9 @@ type DepsCfg struct { type BuildState struct { counter uint64 + // Tracks invocations of the Build method. + BuildCounter atomic.Uint64 + mu sync.Mutex // protects state below. OnSignalRebuild func(ids ...identity.Identity) @@ -472,6 +475,11 @@ type DeferredExecutions struct { var _ identity.SignalRebuilder = (*BuildState)(nil) +// IsRebuild reports whether this is a rebuild. +func (b *BuildState) IsRebuild() bool { + return b.BuildCounter.Load() > 0 +} + // StartStageRender will be called before a stage is rendered. func (b *BuildState) StartStageRender(stage tpl.RenderingContext) { } diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go index 694dd7051..50ae2f2d9 100644 --- a/hugolib/hugo_sites.go +++ b/hugolib/hugo_sites.go @@ -123,8 +123,6 @@ type HugoSites struct { *progressReporter *fatalErrorHandler *buildCounters - // Tracks invocations of the Build method. - buildCounter atomic.Uint64 } // hugoSitesSitesProvider is a wrapper that implements page.SitesProvider. @@ -253,7 +251,7 @@ func (h *HugoSites) Close() error { } func (h *HugoSites) isRebuild() bool { - return h.buildCounter.Load() > 0 + return h.BuildState.IsRebuild() } func (h *HugoSites) resolveFirstSite(matrix sitesmatrix.VectorStore) *Site { @@ -650,7 +648,7 @@ func (cfg *BuildCfg) shouldRender(infol logg.LevelLogger, p *pageState) bool { fastRenderMode := p.s.Conf.FastRenderMode() - if !fastRenderMode || p.s.h.buildCounter.Load() == 0 { + if !fastRenderMode || !p.s.h.BuildState.IsRebuild() { return shouldRender } diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index 84b070d13..98d1d68c7 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -83,7 +83,7 @@ func (h *HugoSites) Build(config BuildCfg, events ...fsnotify.Event) error { h.reportProgress(func() (state terminal.ProgressState, progress float64) { return terminal.ProgressHidden, 1.0 }) - h.buildCounter.Add(1) + h.BuildState.BuildCounter.Add(1) }() if h.Deps == nil { diff --git a/hugolib/page.go b/hugolib/page.go index ef2bd2132..759e4fe36 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -721,7 +721,7 @@ func (ps *pageState) initPage() error { func (ps *pageState) renderResources() error { for _, r := range ps.Resources() { if _, ok := r.(page.Page); ok { - if ps.s.h.buildCounter.Load() == 0 { + if !ps.s.h.BuildState.IsRebuild() { // Pages gets rendered with the owning page but we count them here. ps.s.PathSpec.ProcessingStats.Incr(&ps.s.PathSpec.ProcessingStats.Pages) } diff --git a/hugolib/site_render.go b/hugolib/site_render.go index 2da329255..b69e3dcbc 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -154,7 +154,7 @@ func pageRenderer( } } - if !s.conf.DisableAliases && s.h.buildCounter.Load() == 0 { + if !s.conf.DisableAliases && !s.h.BuildState.IsRebuild() { of := p.outputFormat() if of.IsHTML && of.Permalinkable { // Render any aliases for this page. diff --git a/identity/identity.go b/identity/identity.go index 7cc303ffa..d0823c57f 100644 --- a/identity/identity.go +++ b/identity/identity.go @@ -245,6 +245,11 @@ type SignalRebuilder interface { SignalRebuild(ids ...Identity) } +// IsRebuildProvider signals if we're in a rebuild or not. +type IsRebuildProvider interface { + IsRebuild() bool +} + // IncrementByOne implements Incrementer adding 1 every time Incr is called. type IncrementByOne struct { counter uint64 diff --git a/resources/resource_cache.go b/resources/resource_cache.go index e91b0ea7e..dbdd829d2 100644 --- a/resources/resource_cache.go +++ b/resources/resource_cache.go @@ -22,6 +22,7 @@ import ( "strings" "sync" + "github.com/gohugoio/hugo/common/hmaps" "github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/cache/dynacache" @@ -56,6 +57,8 @@ func newResourceCache(rs *Spec, memCache *dynacache.Cache) *ResourceCache { "/res1/tra", dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40}, ), + + cacheResourceTransformationPublished: hmaps.NewMap[string, string](), } } @@ -68,6 +71,9 @@ type ResourceCache struct { cacheResources *dynacache.Partition[string, resource.Resources] cacheResourceTransformation *dynacache.Partition[string, *resourceAdapterInner] + // Used in rebuilds. Maps the target path to the last published transformation key. + cacheResourceTransformationPublished *hmaps.Map[string, string] + fileCache *filecache.Cache } diff --git a/resources/resource_spec.go b/resources/resource_spec.go index ac2e0c30d..2ffa4f9f2 100644 --- a/resources/resource_spec.go +++ b/resources/resource_spec.go @@ -57,7 +57,7 @@ func NewSpec( errorHandler herrors.ErrorSender, execHelper *hexec.Exec, buildClosers types.CloseAdder, - rebuilder identity.SignalRebuilder, + rebuilder Rebuilder, ) (*Spec, error) { conf := s.Cfg.GetConfig().(*allconfig.Config) imgConfig := conf.Imaging @@ -118,13 +118,18 @@ func NewSpec( return rs, nil } +type Rebuilder interface { + identity.SignalRebuilder + identity.IsRebuildProvider +} + type Spec struct { *helpers.PathSpec Logger loggers.Logger ErrorSender herrors.ErrorSender BuildClosers types.CloseAdder - Rebuilder identity.SignalRebuilder + Rebuilder Rebuilder Permalinks page.PermalinkExpander diff --git a/resources/transform.go b/resources/transform.go index 50f8de30c..5dc281f71 100644 --- a/resources/transform.go +++ b/resources/transform.go @@ -455,7 +455,10 @@ func (r *resourceAdapter) TransformationKey() string { func (r *resourceAdapter) getOrTransform(publish, setContent bool) error { key := r.TransformationKey() + + var created bool res, err := r.spec.ResourceCache.cacheResourceTransformation.GetOrCreate(key, func(string) (*resourceAdapterInner, error) { + created = true return r.transform(key, publish, setContent) }) if err != nil { @@ -463,6 +466,40 @@ func (r *resourceAdapter) getOrTransform(publish, setContent bool) error { } r.resourceAdapterInner = res + + if publish && r.spec.Rebuilder.IsRebuild() { + targetPath := r.target.TargetPath() + var republish bool + + r.spec.ResourceCache.cacheResourceTransformationPublished.WithWriteLock(func(m map[string]string) error { + if created { + m[targetPath] = key + } else { + key2, found := m[targetPath] + republish = !found || key2 != key + m[targetPath] = key + } + return nil + }) + + if !created && republish { + src, err := contentReadSeekerCloser(r.target) + if err != nil { + return err + } + defer src.Close() + dest, err := r.target.openPublishFileForWriting(targetPath) + if err != nil { + return err + } + defer dest.Close() + _, err = io.Copy(dest, src) + if err != nil { + return err + } + } + } + return nil } diff --git a/tpl/css/build_integration_test.go b/tpl/css/build_integration_test.go index f3bf8117a..78682ff83 100644 --- a/tpl/css/build_integration_test.go +++ b/tpl/css/build_integration_test.go @@ -169,6 +169,43 @@ Home. b.AssertFileContent("public/css/main.css", `{background:red}`) } +func TestCSSBuildEditOptionsMultiple(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ["taxonomy", "term", "rss"] +disableLiveReload = true +-- assets/css/main.css -- +body { + background: red; +} +-- layouts/_partials/css.html -- +{{ with resources.Get "css/main.css" }} +{{ $opts := dict "minify" false }} +{{ with . | css.Build $opts }} + +{{ end }} +{{ end }} +-- layouts/all.html -- +All. {{ partial "css.html" . }} +-- content/p1.md -- +-- content/p2.md -- +-- content/p3.md -- + + +` + + b := hugolib.TestRunning(t, files, hugolib.TestOptOsFs()) + + for range 3 { + b.AssertFileContent("public/css/main.css", ` background: red;`) + b.EditFileReplaceAll("layouts/_partials/css.html", `"minify" false`, `"minify" true`).Build() + b.AssertFileContent("public/css/main.css", `{background:red}`) + b.EditFileReplaceAll("layouts/_partials/css.html", `"minify" true`, `"minify" false`).Build() + } +} + func TestCSSBuildSourceMaps(t *testing.T) { t.Parallel() -- 2.39.5