Repository: gohugoio/hugo (language: go)

Issue:
`watch` not working on custom output template

### Reproducer

```yaml
// hugo.yaml
outputFormats:
  SearchIndex:
    baseName: 'Search'
    isPlainText: true
    mediaType: 'application/json'
    noAlternative: true

outputs:
  home:
    - HTML
    - SearchIndex
```

```html
// layouts/_default/index.searchindex.json
{{ partial "index.json" . }}
```

```html
// layouts/partials/index.json
{{ $data := dict
  "version" 1
}}
{{ jsonify $data }}
```

### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.137.0-59c115813595cba1b1c0e70b867e734992648d1b+extended linux/amd64 BuildDate=2024-11-04T16:04:06Z VendorInfo=gohugoio
</pre>

### Does this issue reproduce with the latest release?

Yes. It was also present in earlier versions, such as 0.128.0.



Discussion / hints:
I'm pretty sure the issue title isn't true in general, so it must something ... special.


* What do you mean by `watch`? `hugo server --watch`?
* Do you have a complete and failing example living somewhere?
> * What do you mean by `watch`? `hugo server --watch`?

Yes, `hugo server --watch`.

> * Do you have a complete and failing example living somewhere?

I just created a testing branch with the code snippets mentioned above.

```sh
git clone -b custom-output-watch git@github.com:razonyang/hugo-testing.git hugo-13014

cd hugo-13014

hugo server --watch -p 1111
```

And then I modified the `layouts/partials/index.json`, `http://localhost:1111/search.json` won't re-render until restarting Hugo server.

I can reproduce it on both Linux (as shown in the video I posted) and Windows.
+1 This issue is also happening to me - the changes are not reflecting if change a particular file until we restart the server.
Currently using `hugo v0.114.0+extended darwin/arm64 BuildDate=unknown`

Fix patch:
diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go
index dcc5c099bd3..4c2d865c071 100644
--- a/commands/hugobuilder.go
+++ b/commands/hugobuilder.go
@@ -62,7 +62,7 @@ type hugoBuilder struct {
 
 	// Currently only set when in "fast render mode".
 	changeDetector *fileChangeDetector
-	visitedURLs    *types.EvictingStringQueue
+	visitedURLs    *types.EvictingQueue[string]
 
 	fullRebuildSem *semaphore.Weighted
 	debounce       func(f func())
@@ -1103,7 +1103,7 @@ func (c *hugoBuilder) rebuildSites(events []fsnotify.Event) (err error) {
 	if err != nil {
 		return
 	}
-	err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...)
+	err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...)
 	return
 }
 
@@ -1119,7 +1119,7 @@ func (c *hugoBuilder) rebuildSitesForChanges(ids []identity.Identity) (err error
 	}
 	whatChanged := &hugolib.WhatChanged{}
 	whatChanged.Add(ids...)
-	err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()})
+	err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()})
 
 	return
 }
diff --git a/commands/server.go b/commands/server.go
index c4a8ddd2997..08ecd5bac7d 100644
--- a/commands/server.go
+++ b/commands/server.go
@@ -85,9 +85,9 @@ const (
 )
 
 func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder {
-	var visitedURLs *types.EvictingStringQueue
+	var visitedURLs *types.EvictingQueue[string]
 	if s != nil && !s.disableFastRender {
-		visitedURLs = types.NewEvictingStringQueue(20)
+		visitedURLs = types.NewEvictingQueue[string](20)
 	}
 	return &hugoBuilder{
 		r:              r,
@@ -364,7 +364,10 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string
 			}
 
 			if f.c.fastRenderMode && f.c.errState.buildErr() == nil {
-				if strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
+				// Sec-Fetch-Mode should be sent by all recent browser versions, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode#navigate
+				// Fall back to the file extension if not set.
+				// The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
+				if r.Header.Get("Sec-Fetch-Mode") == "navigate" || strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
 					if !f.c.visitedURLs.Contains(requestURI) {
 						// If not already on stack, re-render that single page.
 						if err := f.c.partialReRender(requestURI); err != nil {
@@ -838,7 +841,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) {
 	defer func() {
 		c.errState.setWasErr(false)
 	}()
-	visited := types.NewEvictingStringQueue(len(urls))
+	visited := types.NewEvictingQueue[string](len(urls))
 	for _, url := range urls {
 		visited.Add(url)
 	}
@@ -850,7 +853,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) {
 	}
 
 	// Note: We do not set NoBuildLock as the file lock is not acquired at this stage.
-	err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyVisited: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()})
+	err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyTouched: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()})
 
 	return
 }
diff --git a/common/types/evictingqueue.go b/common/types/evictingqueue.go
index 5ab715aa871..c3598f19f76 100644
--- a/common/types/evictingqueue.go
+++ b/common/types/evictingqueue.go
@@ -18,24 +18,24 @@ import (
 	"sync"
 )
 
-// EvictingStringQueue is a queue which automatically evicts elements from the head of
+// EvictingQueue is a queue which automatically evicts elements from the head of
 // the queue when attempting to add new elements onto the queue and it is full.
 // This queue orders elements LIFO (last-in-first-out). It throws away duplicates.
-// Note: This queue currently does not contain any remove (poll etc.) methods.
-type EvictingStringQueue struct {
+type EvictingQueue[T comparable] struct {
 	size int
-	vals []string
-	set  map[string]bool
+	vals []T
+	set  map[T]bool
 	mu   sync.Mutex
+	zero T
 }
 
-// NewEvictingStringQueue creates a new queue with the given size.
-func NewEvictingStringQueue(size int) *EvictingStringQueue {
-	return &EvictingStringQueue{size: size, set: make(map[string]bool)}
+// NewEvictingQueue creates a new queue with the given size.
+func NewEvictingQueue[T comparable](size int) *EvictingQueue[T] {
+	return &EvictingQueue[T]{size: size, set: make(map[T]bool)}
 }
 
 // Add adds a new string to the tail of the queue if it's not already there.
-func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue {
+func (q *EvictingQueue[T]) Add(v T) *EvictingQueue[T] {
 	q.mu.Lock()
 	if q.set[v] {
 		q.mu.Unlock()
@@ -54,7 +54,7 @@ func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue {
 	return q
 }
 
-func (q *EvictingStringQueue) Len() int {
+func (q *EvictingQueue[T]) Len() int {
 	if q == nil {
 		return 0
 	}
@@ -64,7 +64,7 @@ func (q *EvictingStringQueue) Len() int {
 }
 
 // Contains returns whether the queue contains v.
-func (q *EvictingStringQueue) Contains(v string) bool {
+func (q *EvictingQueue[T]) Contains(v T) bool {
 	if q == nil {
 		return false
 	}
@@ -74,12 +74,12 @@ func (q *EvictingStringQueue) Contains(v string) bool {
 }
 
 // Peek looks at the last element added to the queue.
-func (q *EvictingStringQueue) Peek() string {
+func (q *EvictingQueue[T]) Peek() T {
 	q.mu.Lock()
 	l := len(q.vals)
 	if l == 0 {
 		q.mu.Unlock()
-		return ""
+		return q.zero
 	}
 	elem := q.vals[l-1]
 	q.mu.Unlock()
@@ -87,9 +87,12 @@ func (q *EvictingStringQueue) Peek() string {
 }
 
 // PeekAll looks at all the elements in the queue, with the newest first.
-func (q *EvictingStringQueue) PeekAll() []string {
+func (q *EvictingQueue[T]) PeekAll() []T {
+	if q == nil {
+		return nil
+	}
 	q.mu.Lock()
-	vals := make([]string, len(q.vals))
+	vals := make([]T, len(q.vals))
 	copy(vals, q.vals)
 	q.mu.Unlock()
 	for i, j := 0, len(vals)-1; i < j; i, j = i+1, j-1 {
@@ -99,9 +102,9 @@ func (q *EvictingStringQueue) PeekAll() []string {
 }
 
 // PeekAllSet returns PeekAll as a set.
-func (q *EvictingStringQueue) PeekAllSet() map[string]bool {
+func (q *EvictingQueue[T]) PeekAllSet() map[T]bool {
 	all := q.PeekAll()
-	set := make(map[string]bool)
+	set := make(map[T]bool)
 	for _, v := range all {
 		set[v] = true
 	}
diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go
index 792d6a990ea..83e8caa7f48 100644
--- a/hugolib/hugo_sites.go
+++ b/hugolib/hugo_sites.go
@@ -416,8 +416,8 @@ type BuildCfg struct {
 	// Set in server mode when the last build failed for some reason.
 	ErrRecovery bool
 
-	// Recently visited URLs. This is used for partial re-rendering.
-	RecentlyVisited *types.EvictingStringQueue
+	// Recently visited or touched URLs. This is used for partial re-rendering.
+	RecentlyTouched *types.EvictingQueue[string]
 
 	// Can be set to build only with a sub set of the content source.
 	ContentInclusionFilter *glob.FilenameFilter
@@ -429,7 +429,7 @@ type BuildCfg struct {
 }
 
 // shouldRender returns whether this output format should be rendered or not.
-func (cfg *BuildCfg) shouldRender(p *pageState) bool {
+func (cfg *BuildCfg) shouldRender(infol logg.LevelLogger, p *pageState) bool {
 	if p.skipRender() {
 		return false
 	}
@@ -457,18 +457,20 @@ func (cfg *BuildCfg) shouldRender(p *pageState) bool {
 		return false
 	}
 
-	if p.outputFormat().IsHTML {
-		// This is fast render mode and the output format is HTML,
-		// rerender if this page is one of the recently visited.
-		return cfg.RecentlyVisited.Contains(p.RelPermalink())
+	if relURL := p.getRelURL(); relURL != "" {
+		if cfg.RecentlyTouched.Contains(relURL) {
+			infol.Logf("render recently touched URL %q (%s)", relURL, p.outputFormat().Name)
+			return true
+		}
 	}
 
 	// In fast render mode, we want to avoid re-rendering the sitemaps etc. and
 	// other big listings whenever we e.g. change a content file,
-	// but we want partial renders of the recently visited pages to also include
+	// but we want partial renders of the recently touched pages to also include
 	// alternative formats of the same HTML page (e.g. RSS, JSON).
 	for _, po := range p.pageOutputs {
-		if po.render && po.f.IsHTML && cfg.RecentlyVisited.Contains(po.RelPermalink()) {
+		if po.render && po.f.IsHTML && cfg.RecentlyTouched.Contains(po.getRelURL()) {
+			infol.Logf("render recently touched URL %q, %s version of %s", po.getRelURL(), po.f.Name, p.outputFormat().Name)
 			return true
 		}
 	}
diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go
index 7e7f61031ca..8f3b71bafbb 100644
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -341,7 +341,7 @@ func (h *HugoSites) render(l logg.LevelLogger, config *BuildCfg) error {
 		loggers.TimeTrackf(l, start, h.buildCounters.loggFields(), "")
 	}()
 
-	siteRenderContext := &siteRenderContext{cfg: config, multihost: h.Configs.IsMultihost}
+	siteRenderContext := &siteRenderContext{cfg: config, infol: l, multihost: h.Configs.IsMultihost}
 
 	renderErr := func(err error) error {
 		if err == nil {
@@ -902,12 +902,12 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo
 
 			needsPagesAssemble = true
 
-			if config.RecentlyVisited != nil {
+			if config.RecentlyTouched != nil {
 				// Fast render mode. Adding them to the visited queue
 				// avoids rerendering them on navigation.
 				for _, id := range changes {
 					if p, ok := id.(page.Page); ok {
-						config.RecentlyVisited.Add(p.RelPermalink())
+						config.RecentlyTouched.Add(p.RelPermalink())
 					}
 				}
 			}
diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go
index 6324b587125..62206cb15ac 100644
--- a/hugolib/page__paths.go
+++ b/hugolib/page__paths.go
@@ -71,11 +71,12 @@ func newPagePaths(ps *pageState) (pagePaths, error) {
 		// Use the main format for permalinks, usually HTML.
 		permalinksIndex := 0
 		if f.Permalinkable {
-			// Unless it's permalinkable
+			// Unless it's permalinkable.
 			permalinksIndex = i
 		}
 
 		targets[f.Name] = targetPathsHolder{
+			relURL:       relPermalink,
 			paths:        paths,
 			OutputFormat: pageOutputFormats[permalinksIndex],
 		}
diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go
index 7c6395e57b5..2915c6b8a5f 100644
--- a/hugolib/page__per_output.go
+++ b/hugolib/page__per_output.go
@@ -469,13 +469,21 @@ type pagePerOutputProviders interface {
 
 type targetPather interface {
 	targetPaths() page.TargetPaths
+	getRelURL() string
 }
 
 type targetPathsHolder struct {
-	paths page.TargetPaths
+	// relURL is usually the same as OutputFormat.RelPermalink, but can be different
+	// for non-permalinkable output formats. These shares RelPermalink with the main (first) output format.
+	relURL string
+	paths  page.TargetPaths
 	page.OutputFormat
 }
 
+func (t targetPathsHolder) getRelURL() string {
+	return t.relURL
+}
+
 func (t targetPathsHolder) targetPaths() page.TargetPaths {
 	return t.paths
 }
diff --git a/hugolib/site_render.go b/hugolib/site_render.go
index e5b2b62ab71..5ac3d5a759f 100644
--- a/hugolib/site_render.go
+++ b/hugolib/site_render.go
@@ -20,6 +20,7 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/bep/logg"
 	"github.com/gohugoio/hugo/common/herrors"
 	"github.com/gohugoio/hugo/hugolib/doctree"
 
@@ -33,6 +34,8 @@ import (
 type siteRenderContext struct {
 	cfg *BuildCfg
 
+	infol logg.LevelLogger
+
 	// languageIdx is the zero based index of the site.
 	languageIdx int
 
@@ -86,7 +89,7 @@ func (s *Site) renderPages(ctx *siteRenderContext) error {
 		Tree: s.pageMap.treePages,
 		Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) {
 			if p, ok := n.(*pageState); ok {
-				if cfg.shouldRender(p) {
+				if cfg.shouldRender(ctx.infol, p) {
 					select {
 					case <-s.h.Done():
 						return true, nil


Test patch:
diff --git a/common/types/evictingqueue_test.go b/common/types/evictingqueue_test.go
index 7489ba88d9b..cd10d3d8ed3 100644
--- a/common/types/evictingqueue_test.go
+++ b/common/types/evictingqueue_test.go
@@ -23,7 +23,7 @@ import (
 func TestEvictingStringQueue(t *testing.T) {
 	c := qt.New(t)
 
-	queue := NewEvictingStringQueue(3)
+	queue := NewEvictingQueue[string](3)
 
 	c.Assert(queue.Peek(), qt.Equals, "")
 	queue.Add("a")
@@ -53,7 +53,7 @@ func TestEvictingStringQueueConcurrent(t *testing.T) {
 	var wg sync.WaitGroup
 	val := "someval"
 
-	queue := NewEvictingStringQueue(3)
+	queue := NewEvictingQueue[string](3)
 
 	for j := 0; j < 100; j++ {
 		wg.Add(1)
diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go
index 18785ce75c1..ff45ec2757f 100644
--- a/hugolib/integrationtest_builder.go
+++ b/hugolib/integrationtest_builder.go
@@ -487,11 +487,11 @@ func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTest
 	if !s.Cfg.Running {
 		panic("BuildPartial can only be used in server mode")
 	}
-	visited := types.NewEvictingStringQueue(len(urls))
+	visited := types.NewEvictingQueue[string](len(urls))
 	for _, url := range urls {
 		visited.Add(url)
 	}
-	buildCfg := BuildCfg{RecentlyVisited: visited, PartialReRender: true}
+	buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true}
 	return s, s.build(buildCfg)
 }
 
diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go
index 793263e0409..dc2c6524f88 100644
--- a/hugolib/rebuild_test.go
+++ b/hugolib/rebuild_test.go
@@ -357,8 +357,8 @@ RegularPages: {{ range .Site.RegularPages }}{{ .RelPermalink }}|{{ end }}$
 }
 
 func TestRebuildRenameDirectoryWithBranchBundleFastRender(t *testing.T) {
-	recentlyVisited := types.NewEvictingStringQueue(10).Add("/a/b/c/")
-	b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyVisited: recentlyVisited} })
+	recentlyVisited := types.NewEvictingQueue[string](10).Add("/a/b/c/")
+	b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyTouched: recentlyVisited} })
 	b.RenameDir("content/mysection", "content/mysectionrenamed").Build()
 	b.AssertFileContent("public/mysectionrenamed/index.html", "My Section")
 	b.AssertFileContent("public/mysectionrenamed/mysectionbundle/index.html", "My Section Bundle")
@@ -1181,6 +1181,49 @@ Content: {{ .Content }}
 	b.AssertFileContent("public/index.html", "Content: <p>Home</p>")
 }
 
+// Issue #13014.
+func TestRebuildEditNotPermalinkableCustomOutputFormatTemplateInFastRenderMode(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+baseURL = "https://example.com/docs/"
+disableLiveReload = true
+[internal]
+fastRenderMode = true
+disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404"]
+[outputFormats]
+  [outputFormats.SearchIndex]
+    baseName = 'Search'
+    isPlainText = true
+    mediaType = 'text/plain'
+    noAlternative = true
+	permalinkable = false
+
+[outputs]
+  home = ['HTML', 'SearchIndex']
+-- content/_index.md --
+---
+title: "Home"
+---
+Home.
+-- layouts/index.html --
+Home.
+-- layouts/_default/index.searchindex.txt --
+Text. {{ .Title }}|{{ .RelPermalink }}|
+
+`
+	b := TestRunning(t, files, TestOptInfo())
+
+	b.AssertFileContent("public/search.txt", "Text.")
+
+	b.EditFileReplaceAll("layouts/_default/index.searchindex.txt", "Text.", "Text Edited.").Build()
+
+	b.BuildPartial("/docs/search.txt")
+
+	b.AssertFileContent("public/search.txt", "Text Edited.")
+}
+
 func TestRebuildVariationsAssetsJSImport(t *testing.T) {
 	t.Parallel()
 	files := `