Repository: gohugoio/hugo (language: go)

Issue:
hugo server does not refresh page when using resources.Concat
Reference: <https://discourse.gohugo.io/t/48181>


I am convinced this is related to `resources.Concat`. The test case worked in v0.122.0. Git bisect says 7285e74090852b5d52f25e577850fa75f4aa8573 is the first "bad" commit, but that isn't terribly helpful.

#### Setup

```
assets/
└── a.css
```

layouts/partials/css.html

```
{{ $styles := slice }}
{{ range resources.Match "*.css" }}
  {{ $styles = $styles | append . }}
{{ end }}

{{ with resources.Concat "styles.css" $styles }}
  <link rel="stylesheet" href="{{ .RelPermalink }}">
{{ end }}
```

layouts/index.html

```text
<head>
  ...
  {{ partial "css.html" . }}
  ...
</head>
```

The test case only has one CSS file, but the number of files concatenated is irrelevant.


#### Example

```text
git clone --single-branch -b hugo-forum-topic-48181 https://github.com/jmooring/hugo-testing hugo-forum-topic-48181
cd hugo-forum-topic-48181
hugo server
```

Then modify assets/a.css while the server is running. The page does not refresh.


Discussion / hints:

Fix patch:
diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go
index eceb46b3d58..898eee341c3 100644
--- a/common/paths/pathparser.go
+++ b/common/paths/pathparser.go
@@ -18,9 +18,11 @@ import (
 	"path/filepath"
 	"runtime"
 	"strings"
+	"sync"
 
 	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/hugofs/files"
+	"github.com/gohugoio/hugo/identity"
 )
 
 var defaultPathParser PathParser
@@ -50,19 +52,42 @@ func NormalizePathStringBasic(s string) string {
 	return s
 }
 
+// ParseIdentity parses component c with path s into a StringIdentity.
+func (pp *PathParser) ParseIdentity(c, s string) identity.StringIdentity {
+	s = NormalizePathStringBasic(s)
+	p := getPath()
+	p.component = c
+	defer putPath(p)
+	p, err := pp.doParse(c, s, p)
+	if err != nil {
+		panic(err)
+	}
+	return identity.StringIdentity(p.IdentifierBase())
+}
+
 // Parse parses component c with path s into Path using Hugo's content path rules.
-func (parser PathParser) Parse(c, s string) *Path {
-	p, err := parser.parse(c, s)
+func (pp *PathParser) Parse(c, s string) *Path {
+	p, err := pp.parse(c, s)
 	if err != nil {
 		panic(err)
 	}
 	return p
 }
 
+func (pp *PathParser) newPath(component string) *Path {
+	return &Path{
+		component:             component,
+		posContainerLow:       -1,
+		posContainerHigh:      -1,
+		posSectionHigh:        -1,
+		posIdentifierLanguage: -1,
+	}
+}
+
 func (pp *PathParser) parse(component, s string) (*Path, error) {
 	ss := NormalizePathStringBasic(s)
 
-	p, err := pp.doParse(component, ss)
+	p, err := pp.doParse(component, ss, pp.newPath(component))
 	if err != nil {
 		return nil, err
 	}
@@ -70,7 +95,7 @@ func (pp *PathParser) parse(component, s string) (*Path, error) {
 	if s != ss {
 		var err error
 		// Preserve the original case for titles etc.
-		p.unnormalized, err = pp.doParse(component, s)
+		p.unnormalized, err = pp.doParse(component, s, pp.newPath(component))
 
 		if err != nil {
 			return nil, err
@@ -82,15 +107,7 @@ func (pp *PathParser) parse(component, s string) (*Path, error) {
 	return p, nil
 }
 
-func (pp *PathParser) doParse(component, s string) (*Path, error) {
-	p := &Path{
-		component:             component,
-		posContainerLow:       -1,
-		posContainerHigh:      -1,
-		posSectionHigh:        -1,
-		posIdentifierLanguage: -1,
-	}
-
+func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
 	hasLang := pp.LanguageIndex != nil
 	hasLang = hasLang && (component == files.ComponentFolderContent || component == files.ComponentFolderLayouts)
 
@@ -220,6 +237,7 @@ const (
 )
 
 type Path struct {
+	// Note: Any additions to this struct should also be added to the pathPool.
 	s string
 
 	posContainerLow  int
@@ -239,6 +257,31 @@ type Path struct {
 	unnormalized *Path
 }
 
+var pathPool = &sync.Pool{
+	New: func() any {
+		return &Path{}
+	},
+}
+
+func getPath() *Path {
+	return pathPool.Get().(*Path)
+}
+
+func putPath(p *Path) {
+	p.s = ""
+	p.posContainerLow = -1
+	p.posContainerHigh = -1
+	p.posSectionHigh = -1
+	p.component = ""
+	p.bundleType = 0
+	p.identifiers = p.identifiers[:0]
+	p.posIdentifierLanguage = -1
+	p.disabled = false
+	p.trimLeadingSlash = false
+	p.unnormalized = nil
+	pathPool.Put(p)
+}
+
 // TrimLeadingSlash returns a copy of the Path with the leading slash removed.
 func (p Path) TrimLeadingSlash() *Path {
 	p.trimLeadingSlash = true
@@ -254,7 +297,7 @@ func (p *Path) norm(s string) string {
 
 // IdentifierBase satifies identity.Identity.
 func (p *Path) IdentifierBase() string {
-	return p.Base()[1:]
+	return p.Base()
 }
 
 // Component returns the component for this path (e.g. "content").
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index f22fe8a7eb5..21d12b0c78b 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -663,7 +663,7 @@ type Configs struct {
 	// All below is set in Init.
 	Languages             langs.Languages
 	LanguagesDefaultFirst langs.Languages
-	ContentPathParser     paths.PathParser
+	ContentPathParser     *paths.PathParser
 
 	configLangs []config.AllProvider
 }
@@ -735,7 +735,7 @@ func (c *Configs) Init() error {
 	c.Languages = languages
 	c.LanguagesDefaultFirst = languagesDefaultFirst
 
-	c.ContentPathParser = paths.PathParser{LanguageIndex: languagesDefaultFirst.AsIndexSet(), IsLangDisabled: c.Base.IsLangDisabled}
+	c.ContentPathParser = &paths.PathParser{LanguageIndex: languagesDefaultFirst.AsIndexSet(), IsLangDisabled: c.Base.IsLangDisabled}
 
 	c.configLangs = make([]config.AllProvider, len(c.Languages))
 	for i, l := range c.LanguagesDefaultFirst {
diff --git a/config/allconfig/configlanguage.go b/config/allconfig/configlanguage.go
index 2cc80caa8f0..9971be65f08 100644
--- a/config/allconfig/configlanguage.go
+++ b/config/allconfig/configlanguage.go
@@ -19,6 +19,7 @@ import (
 	"github.com/gohugoio/hugo/common/paths"
 	"github.com/gohugoio/hugo/common/urls"
 	"github.com/gohugoio/hugo/config"
+	"github.com/gohugoio/hugo/identity"
 	"github.com/gohugoio/hugo/langs"
 )
 
@@ -42,7 +43,7 @@ func (c ConfigLanguage) LanguagesDefaultFirst() langs.Languages {
 	return c.m.LanguagesDefaultFirst
 }
 
-func (c ConfigLanguage) PathParser() paths.PathParser {
+func (c ConfigLanguage) PathParser() *paths.PathParser {
 	return c.m.ContentPathParser
 }
 
@@ -133,6 +134,13 @@ func (c ConfigLanguage) Watching() bool {
 	return c.m.Base.Internal.Watch
 }
 
+func (c ConfigLanguage) NewIdentityManager(name string) identity.Manager {
+	if !c.Watching() {
+		return identity.NopManager
+	}
+	return identity.NewManager(name)
+}
+
 // GetConfigSection is mostly used in tests. The switch statement isn't complete, but what's in use.
 func (c ConfigLanguage) GetConfigSection(s string) any {
 	switch s {
diff --git a/config/configProvider.go b/config/configProvider.go
index 38dde3bb4cc..586a9b758fd 100644
--- a/config/configProvider.go
+++ b/config/configProvider.go
@@ -20,6 +20,7 @@ import (
 	"github.com/gohugoio/hugo/common/paths"
 	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/common/urls"
+	"github.com/gohugoio/hugo/identity"
 	"github.com/gohugoio/hugo/langs"
 )
 
@@ -31,7 +32,7 @@ type AllProvider interface {
 	LanguagePrefix() string
 	BaseURL() urls.BaseURL
 	BaseURLLiveReload() urls.BaseURL
-	PathParser() paths.PathParser
+	PathParser() *paths.PathParser
 	Environment() string
 	IsMultihost() bool
 	IsMultiLingual() bool
@@ -57,6 +58,7 @@ type AllProvider interface {
 	BuildDrafts() bool
 	Running() bool
 	Watching() bool
+	NewIdentityManager(name string) identity.Manager
 	FastRenderMode() bool
 	PrintUnusedTemplates() bool
 	EnableMissingTranslationPlaceholders() bool
diff --git a/hugofs/component_fs.go b/hugofs/component_fs.go
index c3c64b18f43..b6eeb89e5bd 100644
--- a/hugofs/component_fs.go
+++ b/hugofs/component_fs.go
@@ -241,7 +241,7 @@ type ComponentFsOptions struct {
 	DefaultContentLanguage string
 
 	// The parser used to parse paths provided by this filesystem.
-	PathParser paths.PathParser
+	PathParser *paths.PathParser
 }
 
 func (fs *componentFs) Open(name string) (afero.File, error) {
diff --git a/hugolib/content_map.go b/hugolib/content_map.go
index 85300e3db21..23b74e1c7fa 100644
--- a/hugolib/content_map.go
+++ b/hugolib/content_map.go
@@ -93,8 +93,8 @@ func (r *resourceSource) GetIdentity() identity.Identity {
 	return r.path
 }
 
-func (r *resourceSource) ForEeachIdentity(f func(identity.Identity) bool) {
-	f(r.GetIdentity())
+func (r *resourceSource) ForEeachIdentity(f func(identity.Identity) bool) bool {
+	return f(r.GetIdentity())
 }
 
 func (r *resourceSource) Path() string {
@@ -142,14 +142,15 @@ func (n resourceSources) GetIdentity() identity.Identity {
 	return nil
 }
 
-func (n resourceSources) ForEeachIdentity(f func(identity.Identity) bool) {
+func (n resourceSources) ForEeachIdentity(f func(identity.Identity) bool) bool {
 	for _, r := range n {
 		if r != nil {
 			if f(r.GetIdentity()) {
-				return
+				return true
 			}
 		}
 	}
+	return false
 }
 
 func (cfg contentMapConfig) getTaxonomyConfig(s string) (v viewName) {
diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go
index 6273870b73f..576ee5e08ed 100644
--- a/hugolib/content_map_page.go
+++ b/hugolib/content_map_page.go
@@ -605,12 +605,15 @@ func (n contentNodeIs) GetIdentity() identity.Identity {
 	return n[0].GetIdentity()
 }
 
-func (n contentNodeIs) ForEeachIdentity(f func(identity.Identity) bool) {
+func (n contentNodeIs) ForEeachIdentity(f func(identity.Identity) bool) bool {
 	for _, nn := range n {
 		if nn != nil {
-			nn.ForEeachIdentity(f)
+			if nn.ForEeachIdentity(f) {
+				return true
+			}
 		}
 	}
+	return false
 }
 
 func (n contentNodeIs) resetBuildState() {
@@ -1151,7 +1154,7 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context,
 			// First check the top level dependency manager.
 			for _, id := range changes {
 				checkedCounter.Add(1)
-				if r := depsFinder.Contains(id, p.dependencyManager, 100); r > identity.FinderFoundOneOfManyRepetition {
+				if r := depsFinder.Contains(id, p.dependencyManager, 2); r > identity.FinderFoundOneOfManyRepetition {
 					for _, po := range p.pageOutputs {
 						resetPo(po, r)
 					}
@@ -1167,7 +1170,7 @@ func (h *HugoSites) resolveAndResetDependententPageOutputs(ctx context.Context,
 				}
 				for _, id := range changes {
 					checkedCounter.Add(1)
-					if r := depsFinder.Contains(id, po.dependencyManagerOutput, 2); r > identity.FinderFoundOneOfManyRepetition {
+					if r := depsFinder.Contains(id, po.dependencyManagerOutput, 50); r > identity.FinderFoundOneOfManyRepetition {
 						resetPo(po, r)
 						continue OUTPUTS
 					}
diff --git a/hugolib/page.go b/hugolib/page.go
index 10ecdcad2b8..c743d42823d 100644
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -120,8 +120,8 @@ func (p *pageState) GetIdentity() identity.Identity {
 	return p
 }
 
-func (p *pageState) ForEeachIdentity(f func(identity.Identity) bool) {
-	f(p)
+func (p *pageState) ForEeachIdentity(f func(identity.Identity) bool) bool {
+	return f(p)
 }
 
 func (p *pageState) GetDependencyManager() identity.Manager {
diff --git a/hugolib/page__new.go b/hugolib/page__new.go
index 2c2d92ab83e..9dd2f2cdfa9 100644
--- a/hugolib/page__new.go
+++ b/hugolib/page__new.go
@@ -20,7 +20,6 @@ import (
 	"sync/atomic"
 
 	"github.com/gohugoio/hugo/hugofs/files"
-	"github.com/gohugoio/hugo/identity"
 	"github.com/gohugoio/hugo/resources"
 
 	"github.com/gohugoio/hugo/common/maps"
@@ -160,12 +159,6 @@ func (h *HugoSites) newPage(m *pageMeta) (*pageState, *paths.Path, error) {
 			return nil, nil
 		}
 
-		var dependencyManager identity.Manager = identity.NopManager
-
-		if m.s.conf.Internal.Watch {
-			dependencyManager = identity.NewManager(m.Path())
-		}
-
 		// Parse the rest of the page content.
 		m.content, err = m.newCachedContent(h, pi)
 		if err != nil {
@@ -178,7 +171,7 @@ func (h *HugoSites) newPage(m *pageMeta) (*pageState, *paths.Path, error) {
 			pageOutputTemplateVariationsState: &atomic.Uint32{},
 			resourcesPublishInit:              &sync.Once{},
 			Staler:                            m,
-			dependencyManager:                 dependencyManager,
+			dependencyManager:                 m.s.Conf.NewIdentityManager(m.Path()),
 			pageCommon: &pageCommon{
 				FileProvider:              m,
 				AuthorProvider:            m,
diff --git a/hugolib/page__output.go b/hugolib/page__output.go
index 9050766d1d0..2f4d6c205d4 100644
--- a/hugolib/page__output.go
+++ b/hugolib/page__output.go
@@ -51,11 +51,6 @@ func newPageOutput(
 		})
 	}
 
-	var dependencyManager identity.Manager = identity.NopManager
-	if ps.s.conf.Internal.Watch {
-		dependencyManager = identity.NewManager(ps.Path() + "/" + f.Name)
-	}
-
 	providers := struct {
 		page.PaginatorProvider
 		resource.ResourceLinksProvider
@@ -75,7 +70,7 @@ func newPageOutput(
 		TableOfContentsProvider: page.NopPage,
 		render:                  render,
 		paginator:               pag,
-		dependencyManagerOutput: dependencyManager,
+		dependencyManagerOutput: ps.s.Conf.NewIdentityManager((ps.Path() + "/" + f.Name)),
 	}
 
 	return po
diff --git a/identity/finder.go b/identity/finder.go
index bd23d698ef1..91fac72378b 100644
--- a/identity/finder.go
+++ b/identity/finder.go
@@ -169,12 +169,7 @@ func (f *Finder) checkManager(sid *searchID, m Manager, level int) FinderResult
 		return r
 	}
 
-	ids := m.getIdentities()
-	if len(ids) == 0 {
-		r = FinderNotFound
-	} else {
-		r = f.search(sid, ids, level)
-	}
+	r = f.search(sid, m, level)
 
 	if r == FinderFoundOneOfMany {
 		// Don't cache this one.
@@ -270,11 +265,7 @@ func (f *Finder) doCheckOne(sid *searchID, v Identity, depth int) FinderResult {
 }
 
 // search searches for id in ids.
-func (f *Finder) search(sid *searchID, ids Identities, depth int) FinderResult {
-	if len(ids) == 0 {
-		return FinderNotFound
-	}
-
+func (f *Finder) search(sid *searchID, m Manager, depth int) FinderResult {
 	id := sid.id
 
 	if id == Anonymous {
@@ -285,19 +276,24 @@ func (f *Finder) search(sid *searchID, ids Identities, depth int) FinderResult {
 		return FinderNotFound
 	}
 
-	for v := range ids {
-		r := f.checkOne(sid, v, depth)
-		if r > 0 {
-			return r
-		}
-
-		m := GetDependencyManager(v)
-		if r := f.checkManager(sid, m, depth+1); r > 0 {
-			return r
-		}
-	}
-
-	return FinderNotFound
+	var r FinderResult
+	m.forEeachIdentity(
+		func(v Identity) bool {
+			if r > 0 {
+				panic("should be terminated")
+			}
+			r = f.checkOne(sid, v, depth)
+			if r > 0 {
+				return true
+			}
+			m := GetDependencyManager(v)
+			if r = f.checkManager(sid, m, depth+1); r > 0 {
+				return true
+			}
+			return false
+		},
+	)
+	return r
 }
 
 // FinderConfig provides configuration for the Finder.
diff --git a/identity/identity.go b/identity/identity.go
index ccb2f6e79d3..c799759df4d 100644
--- a/identity/identity.go
+++ b/identity/identity.go
@@ -55,8 +55,8 @@ func NewManager(name string, opts ...ManagerOption) Manager {
 // CleanString cleans s to be suitable as an identifier.
 func CleanString(s string) string {
 	s = strings.ToLower(s)
-	s = strings.TrimPrefix(filepath.ToSlash(s), "/")
-	return path.Clean(s)
+	s = strings.Trim(filepath.ToSlash(s), "/")
+	return "/" + path.Clean(s)
 }
 
 // CleanStringIdentity cleans s to be suitable as an identifier and wraps it in a StringIdentity.
@@ -77,23 +77,6 @@ func GetDependencyManager(v any) Manager {
 	return nil
 }
 
-// GetDependencyManagerForScope returns the DependencyManager for the given scope from v or nil if none found.
-// Note that it will fall back to an unscoped manager if none found for the given scope.
-func GetDependencyManagerForScope(v any, scope int) Manager {
-	switch vv := v.(type) {
-	case DependencyManagerScopedProvider:
-		return vv.GetDependencyManagerForScope(scope)
-	case types.Unwrapper:
-		return GetDependencyManagerForScope(vv.Unwrapv(), scope)
-	case Manager:
-		return vv
-	case DependencyManagerProvider:
-		return vv.GetDependencyManager()
-
-	}
-	return nil
-}
-
 // FirstIdentity returns the first Identity in v, Anonymous if none found
 func FirstIdentity(v any) Identity {
 	var result Identity = Anonymous
@@ -169,7 +152,15 @@ type DependencyManagerScopedProvider interface {
 type ForEeachIdentityProvider interface {
 	// ForEeachIdentityProvider calls cb for each Identity.
 	// If cb returns true, the iteration is terminated.
-	ForEeachIdentity(cb func(id Identity) bool)
+	// The return value is whether the iteration was terminated.
+	ForEeachIdentity(cb func(id Identity) bool) bool
+}
+
+// ForEeachIdentityProviderFunc is a function that implements the ForEeachIdentityProvider interface.
+type ForEeachIdentityProviderFunc func(func(id Identity) bool) bool
+
+func (f ForEeachIdentityProviderFunc) ForEeachIdentity(cb func(id Identity) bool) bool {
+	return f(cb)
 }
 
 // ForEeachIdentityByNameProvider provides a way to look up identities by name.
@@ -279,9 +270,10 @@ type IsProbablyDependencyProvider interface {
 type Manager interface {
 	Identity
 	AddIdentity(ids ...Identity)
+	AddIdentityForEach(ids ...ForEeachIdentityProvider)
 	GetIdentity() Identity
 	Reset()
-	getIdentities() Identities
+	forEeachIdentity(func(id Identity) bool) bool
 }
 
 type ManagerOption func(m *identityManager)
@@ -301,8 +293,9 @@ type identityManager struct {
 
 	// mu protects _changes_ to this manager,
 	// reads currently assumes no concurrent writes.
-	mu  sync.RWMutex
-	ids Identities
+	mu         sync.RWMutex
+	ids        Identities
+	forEachIds []ForEeachIdentityProvider
 
 	// Hooks used in debugging.
 	onAddIdentity func(id Identity)
@@ -312,7 +305,7 @@ func (im *identityManager) AddIdentity(ids ...Identity) {
 	im.mu.Lock()
 
 	for _, id := range ids {
-		if id == Anonymous {
+		if id == nil || id == Anonymous {
 			continue
 		}
 		if _, found := im.ids[id]; !found {
@@ -325,6 +318,12 @@ func (im *identityManager) AddIdentity(ids ...Identity) {
 	im.mu.Unlock()
 }
 
+func (im *identityManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
+	im.mu.Lock()
+	im.forEachIds = append(im.forEachIds, ids...)
+	im.mu.Unlock()
+}
+
 func (im *identityManager) ContainsIdentity(id Identity) FinderResult {
 	if im.Identity != Anonymous && id == im.Identity {
 		return FinderFound
@@ -355,10 +354,20 @@ func (im *identityManager) String() string {
 	return fmt.Sprintf("IdentityManager(%s)", im.name)
 }
 
-// TODO(bep) these identities are currently only read on server reloads
-// so there should be no concurrency issues, but that may change.
-func (im *identityManager) getIdentities() Identities {
-	return im.ids
+func (im *identityManager) forEeachIdentity(fn func(id Identity) bool) bool {
+	// The absense of a lock here is debliberate. This is currently opnly used on server reloads
+	// in a single-threaded context.
+	for id := range im.ids {
+		if fn(id) {
+			return true
+		}
+	}
+	for _, fe := range im.forEachIds {
+		if fe.ForEeachIdentity(fn) {
+			return true
+		}
+	}
+	return false
 }
 
 type nopManager int
@@ -366,6 +375,9 @@ type nopManager int
 func (m *nopManager) AddIdentity(ids ...Identity) {
 }
 
+func (m *nopManager) AddIdentityForEach(ids ...ForEeachIdentityProvider) {
+}
+
 func (m *nopManager) IdentifierBase() string {
 	return ""
 }
@@ -377,8 +389,8 @@ func (m *nopManager) GetIdentity() Identity {
 func (m *nopManager) Reset() {
 }
 
-func (m *nopManager) getIdentities() Identities {
-	return nil
+func (m *nopManager) forEeachIdentity(func(id Identity) bool) bool {
+	return false
 }
 
 // returns whether further walking should be terminated.
@@ -401,11 +413,9 @@ func walkIdentities(v any, level int, deep bool, seen map[Identity]bool, cb func
 
 		if deep {
 			if m := GetDependencyManager(id); m != nil {
-				for id2 := range m.getIdentities() {
-					if walkIdentitiesShallow(id2, level+1, cbRecursive) {
-						return true
-					}
-				}
+				m.forEeachIdentity(func(id2 Identity) bool {
+					return walkIdentitiesShallow(id2, level+1, cbRecursive)
+				})
 			}
 		}
 		return false
@@ -420,6 +430,9 @@ func walkIdentitiesShallow(v any, level int, cb func(level int, id Identity) boo
 		if id == Anonymous {
 			return false
 		}
+		if id == nil {
+			return false
+		}
 		return cb(level, id)
 	}
 
diff --git a/resources/resource.go b/resources/resource.go
index 900b05a5f49..73a0ad56e58 100644
--- a/resources/resource.go
+++ b/resources/resource.go
@@ -171,7 +171,7 @@ func (fd *ResourceSourceDescriptor) init(r *Spec) error {
 	fd.MediaType = mediaType
 
 	if fd.DependencyManager == nil {
-		fd.DependencyManager = identity.NopManager
+		fd.DependencyManager = r.Cfg.NewIdentityManager("resource")
 	}
 
 	return nil
diff --git a/resources/resource_cache.go b/resources/resource_cache.go
index a76a51b1c99..bf930c71df0 100644
--- a/resources/resource_cache.go
+++ b/resources/resource_cache.go
@@ -39,7 +39,7 @@ func newResourceCache(rs *Spec, memCache *dynacache.Cache) *ResourceCache {
 		cacheResources: dynacache.GetOrCreatePartition[string, resource.Resources](
 			memCache,
 			"/ress",
-			dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnChange, Weight: 40},
+			dynacache.OptionsPartition{ClearWhen: dynacache.ClearOnRebuild, Weight: 40},
 		),
 		cacheResourceTransformation: dynacache.GetOrCreatePartition[string, *resourceAdapterInner](
 			memCache,
diff --git a/resources/resource_factories/bundler/bundler.go b/resources/resource_factories/bundler/bundler.go
index c255da601ae..dd0f1a4e1b9 100644
--- a/resources/resource_factories/bundler/bundler.go
+++ b/resources/resource_factories/bundler/bundler.go
@@ -20,6 +20,7 @@ import (
 	"path"
 
 	"github.com/gohugoio/hugo/common/hugio"
+	"github.com/gohugoio/hugo/identity"
 	"github.com/gohugoio/hugo/media"
 	"github.com/gohugoio/hugo/resources"
 	"github.com/gohugoio/hugo/resources/resource"
@@ -86,13 +87,32 @@ func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resou
 
 		// The given set of resources must be of the same Media Type.
 		// We may improve on that in the future, but then we need to know more.
-		for i, r := range r {
-			if i > 0 && r.MediaType().Type != resolvedm.Type {
-				return nil, fmt.Errorf("resources in Concat must be of the same Media Type, got %q and %q", r.MediaType().Type, resolvedm.Type)
+		for i, rr := range r {
+			if i > 0 && rr.MediaType().Type != resolvedm.Type {
+				return nil, fmt.Errorf("resources in Concat must be of the same Media Type, got %q and %q", rr.MediaType().Type, resolvedm.Type)
 			}
-			resolvedm = r.MediaType()
+			resolvedm = rr.MediaType()
 		}
 
+		idm := c.rs.Cfg.NewIdentityManager("concat")
+		// Add the concatenated resources as dependencies to the composite resource
+		// so that we can track changes to the individual resources.
+		idm.AddIdentityForEach(identity.ForEeachIdentityProviderFunc(
+			func(f func(identity.Identity) bool) bool {
+				var terminate bool
+				for _, rr := range r {
+					identity.WalkIdentitiesShallow(rr, func(depth int, id identity.Identity) bool {
+						terminate = f(id)
+						return terminate
+					})
+					if terminate {
+						break
+					}
+				}
+				return terminate
+			},
+		))
+
 		concatr := func() (hugio.ReadSeekCloser, error) {
 			var rcsources []hugio.ReadSeekCloser
 			for _, s := range r {
@@ -136,6 +156,7 @@ func (c *Client) Concat(targetPath string, r resource.Resources) (resource.Resou
 				LazyPublish:        true,
 				OpenReadSeekCloser: concatr,
 				TargetPath:         targetPath,
+				DependencyManager:  idm,
 			})
 		if err != nil {
 			return nil, err
diff --git a/resources/resource_factories/create/create.go b/resources/resource_factories/create/create.go
index e98eb742599..0cf84c49b9c 100644
--- a/resources/resource_factories/create/create.go
+++ b/resources/resource_factories/create/create.go
@@ -63,13 +63,6 @@ func (c *Client) Copy(r resource.Resource, targetPath string) (resource.Resource
 	})
 }
 
-func (c *Client) newDependencyManager() identity.Manager {
-	if c.rs.Cfg.Running() {
-		return identity.NewManager("resources")
-	}
-	return identity.NopManager
-}
-
 // Get creates a new Resource by opening the given pathname in the assets filesystem.
 func (c *Client) Get(pathname string) (resource.Resource, error) {
 	pathname = path.Clean(pathname)
@@ -79,7 +72,8 @@ func (c *Client) Get(pathname string) (resource.Resource, error) {
 		// The resource file will not be read before it gets used (e.g. in .Content),
 		// so we need to check that the file exists here.
 		filename := filepath.FromSlash(pathname)
-		if _, err := c.rs.BaseFs.Assets.Fs.Stat(filename); err != nil {
+		fi, err := c.rs.BaseFs.Assets.Fs.Stat(filename)
+		if err != nil {
 			if os.IsNotExist(err) {
 				return nil, nil
 			}
@@ -87,14 +81,16 @@ func (c *Client) Get(pathname string) (resource.Resource, error) {
 			return nil, err
 		}
 
+		pi := fi.(hugofs.FileMetaInfo).Meta().PathInfo
+
 		return c.rs.NewResource(resources.ResourceSourceDescriptor{
 			LazyPublish: true,
 			OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
 				return c.rs.BaseFs.Assets.Fs.Open(filename)
 			},
-			GroupIdentity:     identity.StringIdentity(key),
-			DependencyManager: c.newDependencyManager(),
-			TargetPath:        pathname,
+			Path:          pi,
+			GroupIdentity: pi,
+			TargetPath:    pathname,
 		})
 	})
 }


Test patch:
diff --git a/common/paths/pathparser_test.go b/common/paths/pathparser_test.go
index 27d0b45e9ca..8c89ddd4109 100644
--- a/common/paths/pathparser_test.go
+++ b/common/paths/pathparser_test.go
@@ -349,3 +349,9 @@ func TestHasExt(t *testing.T) {
 	c.Assert(HasExt("/a/b/c"), qt.IsFalse)
 	c.Assert(HasExt("/a/b.c/d"), qt.IsFalse)
 }
+
+func BenchmarkParseIdentity(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		testParser.ParseIdentity(files.ComponentFolderAssets, "/a/b.css")
+	}
+}
diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go
index b1ebe14d53a..9b15fc3e29e 100644
--- a/hugolib/rebuild_test.go
+++ b/hugolib/rebuild_test.go
@@ -1131,7 +1131,7 @@ Single.
 			Running:         true,
 			NeedsOsFS:       true,
 			NeedsNpmInstall: true,
-			// LogLevel:        logg.LevelTrace,
+			// LogLevel:        logg.LevelDebug,
 		},
 	).Build()
 
@@ -1261,3 +1261,35 @@ func BenchmarkRebuildContentFileChange(b *testing.B) {
 		// fmt.Println(bb.LogString())
 	}
 }
+
+func TestRebuildConcat(t *testing.T) {
+	files := `
+-- hugo.toml --
+baseURL = "https://example.com"
+disableLiveReload = true
+disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404", "rss"]
+-- assets/a.css --
+a
+-- assets/b.css --
+b
+-- assets/c.css --
+c
+-- assets/common/c1.css --
+c1
+-- assets/common/c2.css --
+c2
+-- layouts/index.html --
+{{ $a := resources.Get "a.css" }}
+{{ $b := resources.Get "b.css" }}
+{{ $common := resources.Match "common/*.css" | resources.Concat "common.css" | minify }}
+{{ $ab := slice $a $b $common | resources.Concat "ab.css" }}
+all: {{ $ab.RelPermalink }}
+`
+	b := TestRunning(t, files)
+
+	b.AssertFileContent("public/ab.css", "abc1c2")
+	b.EditFileReplaceAll("assets/common/c2.css", "c2", "c2 edited").Build()
+	b.AssertFileContent("public/ab.css", "abc1c2 edited")
+	b.AddFiles("assets/common/c3.css", "c3").Build()
+	b.AssertFileContent("public/ab.css", "abc1c2 editedc3")
+}