Repository: a-h/templ

Issue:
Regression: Templ CLI unnecessarily rebuilds application when text changes inside *.templ files
**Before you begin**
Please make sure you're using the latest version of the templ CLI (`go install github.com/a-h/templ/cmd/templ@latest`), and have upgraded your project to use the latest version of the templ runtime (`go get -u github.com/a-h/templ@latest`)

**Describe the bug**
```
TEMPL_DEV_MODE=true templ generate -v --watch --cmd "go run ."
```
Starting from v0.3.887 (CLI and in go.mod) and up to the latest version, Templ reruns the application when text  (not Go code) is modified inside a `*.templ` file.

I've confirmed this doesn't happen in v0.3.865 and earlier.

**To Reproduce**
A small, self-contained, complete reproduction, uploaded to a GitHub repo, containing the minimum amount of files required to reproduce the behaviour, along with a list of commands that need to be run. Keep it simple.

Repo: https://github.com/BookOfCooks/templ_live_reload_bug

Just run the first command in the README, then make a change to the text in the `templates/home.templ` file. You'll observe that the application is rerun.

**Expected behavior**
A clear and concise description of what you expected to happen.

When I make a simple TEXT change in the `*.templ` file, I expect to reload the page in the browser and see the change, without seeing "Served application on localhost:8080" in the logs.

This should happen, according to the [docs](https://templ.guide/developer-tools/live-reload/#templ-watches-files-for-changes:~:text=The%20web%20server%20is%20only%20restarted%20when%20changes%20are%20made%20to%20Go%20code%20in%20*.templ%20files.).

**Screenshots**
If applicable, add screenshots or screen captures to help explain your problem.

**Logs**
If the issue is related to IDE support, run through the LSP troubleshooting section at https://templ.guide/developer-tools/ide-support/#troubleshooting-1 and include logs from templ

**`templ info` output**
Run `templ info` and include the output.

```
(✓) os [ goos=linux goarch=amd64 ]
(✓) go [ location=/usr/local/go/bin/go version=go version go1.24.5 linux/amd64 ]
(✓) gopls [ location=/home/bookofcooks/go/bin/gopls version=golang.org/x/tools/gopls v0.19.1 ]
(✓) templ [ location=/home/bookofcooks/go/bin/templ version=v0.3.920 ]
```

**Desktop (please complete the following information):**
 - OS: [e.g. MacOS, Linux, Windows, WSL]: WSL
 - templ CLI version (`templ version`): v0.3.920
- Go version (`go version`): go version go1.24.5 linux/amd64
- `gopls` version (`gopls version`): golang.org/x/tools/gopls v0.19.1

**Additional context**
Add any other context about the problem here.


Discussion:
@BookOfCooks: It seems this issue originated with [commit #336d0d6](https://github.com/a-h/templ/commit/336d0d6). Specifically in cmd/templ/generatecmd/cmd.go:286 because of `watchedFileUpdated`.

For now, I'll use my own private fork of the templ cli until this issue is fixed, or if I can get a pull request through.@a-h: I'll work on this as a priority.@a-h: I've tested the PR and it is now working well for me.

The PR diff is a hard read, because I did quite a big refactor, but I think the new code structure is a lot better.@a-h: Released in https://github.com/a-h/templ/releases/tag/v0.3.924@BookOfCooks: Thank you for the quick fix, we'll be using the latest version now 👍 

Solution patch:
diff --git a/cmd/templ/generatecmd/cmd.go b/cmd/templ/generatecmd/cmd.go
index b330fab..b06cca2 100644
--- a/cmd/templ/generatecmd/cmd.go
+++ b/cmd/templ/generatecmd/cmd.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io/fs"
 	"log/slog"
 	"net/http"
 	"net/url"
@@ -12,10 +13,11 @@ import (
 	"path/filepath"
 	"strings"
 	"sync"
-	"sync/atomic"
 	"time"
 
+	"github.com/a-h/templ/internal/skipdir"
 	templruntime "github.com/a-h/templ/runtime"
+	"golang.org/x/sync/errgroup"
 
 	"github.com/a-h/templ"
 	"github.com/a-h/templ/cmd/templ/generatecmd/modcheck"
@@ -42,10 +44,10 @@ type Generate struct {
 }
 
 type GenerationEvent struct {
-	Event              fsnotify.Event
-	WatchedFileUpdated bool
-	GoUpdated          bool
-	TextUpdated        bool
+	Event                fsnotify.Event
+	WatchedFileUpdated   bool
+	TemplFileTextUpdated bool
+	TemplFileGoUpdated   bool
 }
 
 func (cmd Generate) Run(ctx context.Context) (err error) {
@@ -104,193 +106,39 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 	// Start timer.
 	start := time.Now()
 
-	// Create channels:
 	// For the initial filesystem walk and subsequent (optional) fsnotify events.
 	events := make(chan fsnotify.Event)
-	// Count of events currently being processed by the event handler.
-	var eventsWG sync.WaitGroup
-	// Used to check that the event handler has completed.
-	var eventHandlerWG sync.WaitGroup
 	// For errs from the watcher.
 	errs := make(chan error)
-	// Tracks whether errors occurred during the generation process.
-	var errorCount atomic.Int64
-	// For triggering actions after generation has completed.
-	postGeneration := make(chan *GenerationEvent, 256)
-	// Used to check that the post-generation handler has completed.
-	var postGenerationWG sync.WaitGroup
-	var postGenerationEventsWG sync.WaitGroup
-
-	// Waitgroup for the push process.
-	var pushHandlerWG sync.WaitGroup
 
-	// Start process to push events into the channel.
-	pushHandlerWG.Add(1)
-	go func() {
-		defer pushHandlerWG.Done()
+	// Start process to push events into the events channel.
+	grp, ctx := errgroup.WithContext(ctx)
+	grp.Go(func() error {
 		defer close(events)
-		cmd.Log.Debug(
-			"Walking directory",
-			slog.String("path", cmd.Args.Path),
-			slog.Bool("devMode", cmd.Args.Watch),
-		)
-		if err := watcher.WalkFiles(ctx, cmd.Args.Path, cmd.Args.WatchPattern, events); err != nil {
-			cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err))
-			errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)}
-			return
-		}
-		if !cmd.Args.Watch {
-			cmd.Log.Debug("Dev mode not enabled, process can finish early")
-			return
-		}
-		cmd.Log.Info("Watching files")
-		rw, err := watcher.Recursive(ctx, cmd.Args.Path, cmd.Args.WatchPattern, events, errs)
-		if err != nil {
-			cmd.Log.Error("Recursive watcher setup failed, exiting", slog.Any("error", err))
-			errs <- FatalError{Err: fmt.Errorf("failed to setup recursive watcher: %w", err)}
-			return
-		}
-		cmd.Log.Debug("Waiting for context to be cancelled to stop watching files")
-		<-ctx.Done()
-		cmd.Log.Debug("Context cancelled, closing watcher")
-		if err := rw.Close(); err != nil {
-			cmd.Log.Error("Failed to close watcher", slog.Any("error", err))
-		}
-		cmd.Log.Debug("Waiting for events to be processed")
-		eventsWG.Wait()
-		cmd.Log.Debug(
-			"All pending events processed, waiting for pending post-generation events to complete",
-		)
-		postGenerationEventsWG.Wait()
-		cmd.Log.Debug(
-			"All post-generation events processed, deleting watch mode text files",
-			slog.Int64("errorCount", errorCount.Load()),
-		)
-		fileEvents := make(chan fsnotify.Event)
-		go func() {
-			defer close(fileEvents)
-			if err := watcher.WalkFiles(ctx, cmd.Args.Path, cmd.Args.WatchPattern, fileEvents); err != nil {
-				cmd.Log.Error("Post dev mode WalkFiles failed", slog.Any("error", err))
-				errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)}
-				return
-			}
-		}()
-		for event := range fileEvents {
-			if strings.HasSuffix(event.Name, "_templ.go") || strings.HasSuffix(event.Name, ".templ") {
-				watchModeFileName := templruntime.GetDevModeTextFileName(event.Name)
-				if err := os.Remove(watchModeFileName); err != nil && !errors.Is(err, os.ErrNotExist) {
-					cmd.Log.Warn("Failed to remove watch mode text file", slog.Any("error", err))
-				}
-			}
-		}
-	}()
+		cmd.walkAndWatch(ctx, events, errs)
+		return nil
+	})
+
+	// For triggering actions after generation has completed.
+	postGeneration := make(chan *GenerationEvent, 256)
 
 	// Start process to handle events.
-	eventHandlerWG.Add(1)
-	sem := make(chan struct{}, cmd.Args.WorkerCount)
-	go func() {
-		defer eventHandlerWG.Done()
+	grp.Go(func() error {
 		defer close(postGeneration)
-		cmd.Log.Debug("Starting event handler")
-		for event := range events {
-			eventsWG.Add(1)
-			sem <- struct{}{}
-			go func(event fsnotify.Event) {
-				cmd.Log.Debug("Processing file", slog.String("file", event.Name))
-				defer eventsWG.Done()
-				defer func() { <-sem }()
-				r, err := fseh.HandleEvent(ctx, event)
-				if err != nil {
-					errs <- err
-				}
-				if !r.GoUpdated && !r.TextUpdated && !r.WatchedfileUpdated {
-					cmd.Log.Debug("File not updated", slog.String("file", event.Name))
-					return
-				}
-				e := &GenerationEvent{
-					Event:              event,
-					WatchedFileUpdated: r.WatchedfileUpdated,
-					GoUpdated:          r.GoUpdated,
-					TextUpdated:        r.TextUpdated,
-				}
-				cmd.Log.Debug("File updated", slog.String("file", event.Name))
-				postGeneration <- e
-			}(event)
-		}
-		// Wait for all events to be processed before closing.
-		eventsWG.Wait()
-	}()
+		cmd.handleEvents(ctx, events, errs, fseh, postGeneration)
+		return nil
+	})
 
 	// Start process to handle post-generation events.
 	var updates int
-	postGenerationWG.Add(1)
-	var firstPostGenerationExecuted bool
-	go func() {
+	grp.Go(func() error {
 		defer close(errs)
-		defer postGenerationWG.Done()
-		cmd.Log.Debug("Starting post-generation handler")
-		timeout := time.NewTimer(time.Hour * 24 * 365)
-		var goUpdated, textUpdated, watchedFileUpdated bool
-		var p *proxy.Handler
-		for {
-			select {
-			case ge := <-postGeneration:
-				if ge == nil {
-					cmd.Log.Debug("Post-generation event channel closed, exiting")
-					return
-				}
-				goUpdated = goUpdated || ge.GoUpdated
-				watchedFileUpdated = watchedFileUpdated || ge.WatchedFileUpdated
-				textUpdated = textUpdated || ge.TextUpdated
-				if goUpdated || textUpdated || watchedFileUpdated {
-					updates++
-				}
-				// Reset timer.
-				timeout.Stop()
-				timeout.Reset(time.Millisecond * 100)
-			case <-timeout.C:
-				if !goUpdated && !textUpdated && !watchedFileUpdated {
-					// Nothing to process, reset timer and wait again.
-					timeout.Reset(time.Hour * 24 * 365)
-					break
-				}
-				postGenerationEventsWG.Add(1)
-				fmt.Println("Post-generation event received, processing...", cmd.Args.Command, goUpdated, watchedFileUpdated)
-				if cmd.Args.Command != "" && (goUpdated || watchedFileUpdated) {
-					cmd.Log.Info("Executing command", slog.String("command", cmd.Args.Command))
-					if cmd.Args.Watch {
-						if err := os.Setenv("TEMPL_DEV_MODE", "true"); err != nil {
-							cmd.Log.Error("Error setting TEMPL_DEV_MODE environment variable", slog.Any("error", err))
-						}
-					}
-					if _, err := run.Run(ctx, cmd.Args.Path, cmd.Args.Command); err != nil {
-						cmd.Log.Error("Error executing command", slog.Any("error", err))
-					}
-				}
-				if !firstPostGenerationExecuted {
-					cmd.Log.Debug("First post-generation event received, starting proxy")
-					firstPostGenerationExecuted = true
-					p, err = cmd.StartProxy(ctx)
-					if err != nil {
-						cmd.Log.Error("Failed to start proxy", slog.Any("error", err))
-					}
-				}
-				// Send server-sent event.
-				if p != nil && (textUpdated || goUpdated || watchedFileUpdated) {
-					cmd.Log.Debug("Sending reload event")
-					p.SendSSE("message", "reload")
-				}
-				postGenerationEventsWG.Done()
-				// Reset timer.
-				timeout.Reset(time.Millisecond * 100)
-				textUpdated = false
-				goUpdated = false
-				watchedFileUpdated = false
-			}
-		}
-	}()
+		updates, err = cmd.handlePostGenerationEvents(ctx, postGeneration)
+		return err
+	})
 
 	// Read errors.
+	var errorCount int
 	for err := range errs {
 		if err == nil {
 			continue
@@ -300,16 +148,14 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 			return err
 		}
 		cmd.Log.Error("Error", slog.Any("error", err))
-		errorCount.Add(1)
+		errorCount++
 	}
 
 	// Wait for everything to complete.
-	cmd.Log.Debug("Waiting for push handler to complete")
-	pushHandlerWG.Wait()
-	cmd.Log.Debug("Waiting for event handler to complete")
-	eventHandlerWG.Wait()
-	cmd.Log.Debug("Waiting for post-generation handler to complete")
-	postGenerationWG.Wait()
+	cmd.Log.Debug("Waiting for processes to complete")
+	if err = grp.Wait(); err != nil {
+		return err
+	}
 	if cmd.Args.Command != "" {
 		cmd.Log.Debug("Killing command", slog.String("command", cmd.Args.Command))
 		if err := run.KillAll(); err != nil {
@@ -317,24 +163,188 @@ func (cmd Generate) Run(ctx context.Context) (err error) {
 		}
 	}
 
+	// Clean up temporary watch mode text files.
+	if err := cmd.deleteWatchModeTextFiles(); err != nil {
+		cmd.Log.Warn("Failed to delete watch mode text files", slog.Any("error", err))
+	}
+
 	// Check for errors after everything has completed.
-	if errorCount.Load() > 0 {
-		return fmt.Errorf("generation completed with %d errors", errorCount.Load())
+	if errorCount > 0 {
+		return fmt.Errorf("generation completed with %d errors", errorCount)
 	}
 
-	cmd.Log.Info(
-		"Complete",
-		slog.Int("updates", updates),
-		slog.Duration("duration", time.Since(start)),
-	)
+	cmd.Log.Info("Complete", slog.Int("updates", updates), slog.Duration("duration", time.Since(start)))
 	return nil
 }
 
-func (cmd *Generate) StartProxy(ctx context.Context) (p *proxy.Handler, err error) {
-	if cmd.Args.Proxy == "" {
-		cmd.Log.Debug("No proxy URL specified, not starting proxy")
-		return nil, nil
+func (cmd Generate) groupUntilNoMessagesReceivedFor100ms(postGeneration chan *GenerationEvent) (grouped *GenerationEvent, updates int, ok bool, err error) {
+	timeout := time.NewTimer(time.Hour * 24 * 365)
+loop:
+	for {
+		select {
+		case ge := <-postGeneration:
+			if ge == nil {
+				cmd.Log.Debug("Post-generation event channel closed, exiting")
+				return nil, 0, false, nil
+			}
+			if grouped == nil {
+				grouped = ge
+			}
+			grouped.WatchedFileUpdated = grouped.WatchedFileUpdated || ge.WatchedFileUpdated
+			grouped.TemplFileTextUpdated = grouped.TemplFileTextUpdated || ge.TemplFileTextUpdated
+			grouped.TemplFileGoUpdated = grouped.TemplFileGoUpdated || ge.TemplFileGoUpdated
+			if grouped.WatchedFileUpdated || grouped.TemplFileTextUpdated || grouped.TemplFileGoUpdated {
+				updates++
+			}
+			// Now we have received an event, wait for 100ms.
+			// If no further messages are received in that time, the timeout will trigger.
+			timeout = time.NewTimer(time.Millisecond * 100)
+		case <-timeout.C:
+			// If grouped is nil, or if no updates were made, reset the timer and continue waiting.
+			if grouped == nil || (!grouped.WatchedFileUpdated && !grouped.TemplFileTextUpdated && !grouped.TemplFileGoUpdated) {
+				timeout = time.NewTimer(time.Hour * 24 * 365)
+				continue loop
+			}
+			// We have a grouped event, and no events have been sent in the last 100ms, so we need to return.
+			return grouped, updates, true, nil
+		}
+	}
+}
+
+func (cmd Generate) handlePostGenerationEvents(ctx context.Context, postGeneration chan *GenerationEvent) (updates int, err error) {
+	cmd.Log.Debug("Starting post-generation handler")
+	var p *proxy.Handler
+loop:
+	for {
+		grouped, updated, ok, err := cmd.groupUntilNoMessagesReceivedFor100ms(postGeneration)
+		if err != nil {
+			return 0, fmt.Errorf("error grouping post-generation events: %w", err)
+		}
+		if !ok {
+			break loop
+		}
+
+		// The Go application needs to be restarted if any watched non-templ watched files (i.e. non-templ Go files)
+		// were updated, or if any Go code within a templ file was updated.
+		needsRestart := grouped.WatchedFileUpdated || grouped.TemplFileGoUpdated
+		// If the text in a templ file, or any other changes have happened, reload the browser.
+		needsBrowserReload := grouped.TemplFileTextUpdated || grouped.TemplFileGoUpdated || grouped.WatchedFileUpdated
+
+		cmd.Log.Info("Post-generation event received, processing...", slog.Bool("needsRestart", needsRestart), slog.Bool("needsBrowserReload", needsBrowserReload))
+		updates += updated
+
+		if cmd.Args.Command != "" && needsRestart {
+			cmd.Log.Info("Executing command", slog.String("command", cmd.Args.Command))
+			if cmd.Args.Watch {
+				if err := os.Setenv("TEMPL_DEV_MODE", "true"); err != nil {
+					cmd.Log.Error("Error setting TEMPL_DEV_MODE environment variable", slog.Any("error", err))
+				}
+			}
+			if _, err := run.Run(ctx, cmd.Args.Path, cmd.Args.Command); err != nil {
+				cmd.Log.Error("Error executing command", slog.Any("error", err))
+			}
+		}
+		if cmd.Args.Proxy != "" {
+			if p == nil {
+				cmd.Log.Debug("Starting proxy...")
+				p, err = cmd.startProxy()
+				if err != nil {
+					cmd.Log.Error("Failed to start proxy", slog.Any("error", err))
+				}
+			}
+			if needsBrowserReload {
+				cmd.Log.Debug("Sending reload event")
+				p.SendSSE("message", "reload")
+			}
+		}
+	}
+	return updates, nil
+}
+
+func (cmd Generate) handleEvents(ctx context.Context, events chan fsnotify.Event, errs chan error, fseh *FSEventHandler, postGeneration chan *GenerationEvent) {
+	var eventsWG sync.WaitGroup
+	sem := make(chan struct{}, cmd.Args.WorkerCount)
+	cmd.Log.Debug("Starting event handler")
+	for event := range events {
+		eventsWG.Add(1)
+		sem <- struct{}{}
+		go func(event fsnotify.Event) {
+			cmd.Log.Debug("Processing file", slog.String("file", event.Name))
+			defer eventsWG.Done()
+			defer func() { <-sem }()
+			r, err := fseh.HandleEvent(ctx, event)
+			if err != nil {
+				errs <- err
+			}
+			if !r.WatchedFileUpdated && !r.TemplFileTextUpdated && !r.TemplFileGoUpdated {
+				cmd.Log.Debug("File not updated", slog.String("file", event.Name))
+				return
+			}
+			e := &GenerationEvent{
+				Event:                event,
+				WatchedFileUpdated:   r.WatchedFileUpdated,
+				TemplFileTextUpdated: r.TemplFileTextUpdated,
+				TemplFileGoUpdated:   r.TemplFileGoUpdated,
+			}
+			cmd.Log.Debug("File updated", slog.String("file", event.Name))
+			postGeneration <- e
+		}(event)
+	}
+	// Wait for all events to be processed before closing.
+	eventsWG.Wait()
+}
+
+func (cmd *Generate) walkAndWatch(ctx context.Context, events chan fsnotify.Event, errs chan error) {
+	cmd.Log.Debug("Walking directory", slog.String("path", cmd.Args.Path), slog.Bool("devMode", cmd.Args.Watch))
+	if err := watcher.WalkFiles(ctx, cmd.Args.Path, cmd.Args.WatchPattern, events); err != nil {
+		cmd.Log.Error("WalkFiles failed, exiting", slog.Any("error", err))
+		errs <- FatalError{Err: fmt.Errorf("failed to walk files: %w", err)}
+		return
 	}
+	if !cmd.Args.Watch {
+		cmd.Log.Debug("Dev mode not enabled, process can finish early")
+		return
+	}
+	cmd.Log.Info("Watching files")
+	rw, err := watcher.Recursive(ctx, cmd.Args.Path, cmd.Args.WatchPattern, events, errs)
+	if err != nil {
+		cmd.Log.Error("Recursive watcher setup failed, exiting", slog.Any("error", err))
+		errs <- FatalError{Err: fmt.Errorf("failed to setup recursive watcher: %w", err)}
+		return
+	}
+	defer func() {
+		if err := rw.Close(); err != nil {
+			cmd.Log.Error("Failed to close watcher", slog.Any("error", err))
+		}
+	}()
+	cmd.Log.Debug("Waiting for context to be cancelled to stop watching files")
+	<-ctx.Done()
+}
+
+func (cmd *Generate) deleteWatchModeTextFiles() error {
+	return fs.WalkDir(os.DirFS(cmd.Args.Path), ".", func(path string, info os.DirEntry, err error) error {
+		if err != nil {
+			return nil
+		}
+		absPath, err := filepath.Abs(filepath.Join(cmd.Args.Path, path))
+		if err != nil {
+			return nil
+		}
+		if info.IsDir() && skipdir.ShouldSkip(absPath) {
+			return filepath.SkipDir
+		}
+		if !strings.HasSuffix(absPath, "_templ.go") && !strings.HasSuffix(absPath, ".templ") {
+			return nil
+		}
+		watchModeFileName := templruntime.GetDevModeTextFileName(absPath)
+		if err := os.Remove(watchModeFileName); err != nil && !errors.Is(err, os.ErrNotExist) {
+			cmd.Log.Warn("Failed to remove watch mode text file", slog.Any("error", err))
+		}
+		return nil
+	})
+}
+
+func (cmd *Generate) startProxy() (p *proxy.Handler, err error) {
 	var target *url.URL
 	target, err = url.Parse(cmd.Args.Proxy)
 	if err != nil {
@@ -362,11 +372,7 @@ func (cmd *Generate) StartProxy(ctx context.Context) (p *proxy.Handler, err erro
 				break
 			}
 			d := backoff.NextBackOff()
-			cmd.Log.Debug(
-				"Proxy not ready, retrying",
-				slog.String("url", p.URL),
-				slog.Any("backoff", d),
-			)
+			cmd.Log.Debug("Proxy not ready, retrying", slog.String("url", p.URL), slog.Any("backoff", d))
 			time.Sleep(d)
 		}
 		if err := browser.OpenURL(p.URL); err != nil {
diff --git a/cmd/templ/generatecmd/eventhandler.go b/cmd/templ/generatecmd/eventhandler.go
index 54d3a24..b0504aa 100644
--- a/cmd/templ/generatecmd/eventhandler.go
+++ b/cmd/templ/generatecmd/eventhandler.go
@@ -88,12 +88,12 @@ type FSEventHandler struct {
 }
 
 type GenerateResult struct {
-	// WatchedfileUpdated indicates that a file matching the watch pattern was updated.
-	WatchedfileUpdated bool
-	// GoUpdated indicates that Go expressions were updated.
-	GoUpdated bool
-	// TextUpdated indicates that text literals were updated.
-	TextUpdated bool
+	// WatchedFileUpdated indicates that a file matching the watch pattern was updated.
+	WatchedFileUpdated bool
+	// TemplFileTextUpdated indicates that text literals were updated.
+	TemplFileTextUpdated bool
+	// TemplFileGoUpdated indicates that Go expressions were updated.
+	TemplFileGoUpdated bool
 }
 
 func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event) (result GenerateResult, err error) {
@@ -111,7 +111,7 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event)
 		if err = os.Remove(event.Name); err != nil {
 			h.Log.Warn("Failed to remove orphaned file", slog.Any("error", err))
 		}
-		return GenerateResult{WatchedfileUpdated: true, GoUpdated: true, TextUpdated: false}, nil
+		return GenerateResult{WatchedFileUpdated: false, TemplFileGoUpdated: true, TemplFileTextUpdated: false}, nil
 	}
 
 	// If the file hasn't been updated since the last time we processed it, ignore it.
@@ -130,11 +130,10 @@ func (h *FSEventHandler) HandleEvent(ctx context.Context, event fsnotify.Event)
 
 	// Process anything that isn't a templ file.
 	if !strings.HasSuffix(event.Name, ".templ") {
-		// If it's a Go file, mark it as updated.
-		if strings.HasSuffix(event.Name, ".go") {
-			result.GoUpdated = true
+		if h.devMode {
+			h.Log.Info("Watched file updated", slog.String("file", event.Name))
 		}
-		result.WatchedfileUpdated = true
+		result.WatchedFileUpdated = true
 		return result, nil
 	}
 
@@ -216,7 +215,6 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (result
 	// Hash output, and write out the file if the goCodeHash has changed.
 	goCodeHash := sha256.Sum256(formattedGoCode)
 	if h.hashes.CompareAndSwap(targetFileName, syncmap.UpdateIfChanged, goCodeHash) {
-		result.WatchedfileUpdated = true
 		if err = h.writer(targetFileName, formattedGoCode); err != nil {
 			return result, nil, fmt.Errorf("failed to write target file %q: %w", targetFileName, err)
 		}
@@ -229,16 +227,15 @@ func (h *FSEventHandler) generate(ctx context.Context, fileName string) (result
 		joined := strings.Join(generatorOutput.Literals, "\n")
 		txtHash := sha256.Sum256([]byte(joined))
 		if h.hashes.CompareAndSwap(txtFileName, syncmap.UpdateIfChanged, txtHash) {
-			result.TextUpdated = true
 			if err = os.WriteFile(txtFileName, []byte(joined), 0o644); err != nil {
 				return result, nil, fmt.Errorf("failed to write string literal file %q: %w", txtFileName, err)
 			}
 		}
-
-		// Check whether the change would require a recompilation to take effect.
+		// Check whether the change would require a recompilation or text update to take effect.
 		previous, hasPrevious := h.fileNameToOutput.Get(fileName)
-		if hasPrevious && generator.HasChanged(previous, generatorOutput) {
-			result.GoUpdated = true
+		if hasPrevious {
+			result.TemplFileTextUpdated = generator.HasTextChanged(previous, generatorOutput)
+			result.TemplFileGoUpdated = generator.HasGoChanged(previous, generatorOutput)
 		}
 		h.fileNameToOutput.Set(fileName, generatorOutput)
 	}
diff --git a/cmd/templ/generatecmd/watcher/watch.go b/cmd/templ/generatecmd/watcher/watch.go
index 57ab236..c70187a 100644
--- a/cmd/templ/generatecmd/watcher/watch.go
+++ b/cmd/templ/generatecmd/watcher/watch.go
@@ -42,10 +42,8 @@ func NewRecursiveWatcher(ctx context.Context, w *fsnotify.Watcher, watchPattern
 
 // WalkFiles walks the file tree rooted at path, sending a Create event for each
 // file it encounters.
-func WalkFiles(ctx context.Context, path string, watchPattern *regexp.Regexp, out chan fsnotify.Event) (err error) {
-	rootPath := path
-	fileSystem := os.DirFS(rootPath)
-	return fs.WalkDir(fileSystem, ".", func(path string, info os.DirEntry, err error) error {
+func WalkFiles(ctx context.Context, rootPath string, watchPattern *regexp.Regexp, out chan fsnotify.Event) (err error) {
+	return fs.WalkDir(os.DirFS(rootPath), ".", func(path string, info os.DirEntry, err error) error {
 		if err != nil {
 			return nil
 		}
diff --git a/generator/generator.go b/generator/generator.go
index 11204c1..6476741 100644
--- a/generator/generator.go
+++ b/generator/generator.go
@@ -76,9 +76,8 @@ type GeneratorOptions struct {
 	GeneratedDate string
 }
 
-// HasChanged returns true if the generated file should be written to disk, and therefore, also
-// requires a recompilation.
-func HasChanged(previous, updated GeneratorOutput) bool {
+// HasGoChanged returns true if the Go code has changed between the previous and updated GeneratorOutput.
+func HasGoChanged(previous, updated GeneratorOutput) bool {
 	// If generator options have changed, we need to recompile.
 	if previous.Options.Version != updated.Options.Version {
 		return true
@@ -106,6 +105,19 @@ func HasChanged(previous, updated GeneratorOutput) bool {
 	return false
 }
 
+// HasTextChanged returns true if the text literals have changed between the previous and updated GeneratorOutput.
+func HasTextChanged(previous, updated GeneratorOutput) bool {
+	if len(previous.Literals) != len(updated.Literals) {
+		return true
+	}
+	for i, prev := range previous.Literals {
+		if prev != updated.Literals[i] {
+			return true
+		}
+	}
+	return false
+}
+
 // Generate generates Go code from the input template file to w, and returns a map of the location of Go expressions in the template
 // to the location of the generated Go code in the output.
 func Generate(template *parser.TemplateFile, w io.Writer, opts ...GenerateOpt) (op GeneratorOutput, err error) {
diff --git a/parser/v2/gocommentparser.go b/parser/v2/gocommentparser.go
index b12295a..e8b0ec9 100644
--- a/parser/v2/gocommentparser.go
+++ b/parser/v2/gocommentparser.go
@@ -15,6 +15,7 @@ var goSingleLineComment = goSingleLineCommentParser{}
 
 func (p goSingleLineCommentParser) Parse(pi *parse.Input) (n Node, ok bool, err error) {
 	// Comment start.
+	start := pi.Position()
 	if _, ok, err = goSingleLineCommentStart.Parse(pi); err != nil || !ok {
 		return
 	}
@@ -27,6 +28,7 @@ func (p goSingleLineCommentParser) Parse(pi *parse.Input) (n Node, ok bool, err
 	}
 	// Return the comment.
 	c.Multiline = false
+	c.Range = NewRange(start, pi.Position())
 	return c, true, nil
 }
 
@@ -57,6 +59,7 @@ func (p goMultiLineCommentParser) Parse(pi *parse.Input) (n Node, ok bool, err e
 	_, _, _ = goMultiLineCommentEnd.Parse(pi)
 	// Return the comment.
 	c.Multiline = true
+	c.Range = NewRange(start, pi.Position())
 	return c, true, nil
 }
 
diff --git a/parser/v2/types.go b/parser/v2/types.go
index 6234c0e..405aa05 100644
--- a/parser/v2/types.go
+++ b/parser/v2/types.go
@@ -1117,6 +1117,7 @@ func CopyAttributes(attrs []Attribute) (copies []Attribute) {
 type GoComment struct {
 	Contents  string
 	Multiline bool
+	Range     Range
 }
 
 func (c *GoComment) IsNode() bool { return true }



Test patch:
diff --git a/parser/v2/gocommentparser_test.go b/parser/v2/gocommentparser_test.go
index 96ea389..3a4a503 100644
--- a/parser/v2/gocommentparser_test.go
+++ b/parser/v2/gocommentparser_test.go
@@ -20,6 +20,10 @@ func TestGoCommentParser(t *testing.T) {
 			expected: &GoComment{
 				Contents:  " single line comment",
 				Multiline: false,
+				Range: Range{
+					From: Position{Index: 0, Line: 0, Col: 0},
+					To:   Position{Index: 22, Line: 0, Col: 22},
+				},
 			},
 		},
 		{
@@ -28,6 +32,10 @@ func TestGoCommentParser(t *testing.T) {
 			expected: &GoComment{
 				Contents:  " single line comment",
 				Multiline: false,
+				Range: Range{
+					From: Position{Index: 0, Line: 0, Col: 0},
+					To:   Position{Index: 22, Line: 0, Col: 22},
+				},
 			},
 		},
 		{
@@ -36,6 +44,10 @@ func TestGoCommentParser(t *testing.T) {
 			expected: &GoComment{
 				Contents:  " multiline comment, on one line ",
 				Multiline: true,
+				Range: Range{
+					From: Position{Index: 0, Line: 0, Col: 0},
+					To:   Position{Index: 36, Line: 0, Col: 36},
+				},
 			},
 		},
 		{
@@ -45,6 +57,10 @@ on multiple lines */`,
 			expected: &GoComment{
 				Contents:  " multiline comment,\non multiple lines ",
 				Multiline: true,
+				Range: Range{
+					From: Position{Index: 0, Line: 0, Col: 0},
+					To:   Position{Index: 42, Line: 1, Col: 20},
+				},
 			},
 		},
 	}
diff --git a/parser/v2/ifexpressionparser_test.go b/parser/v2/ifexpressionparser_test.go
index 028f326..2c6c578 100644
--- a/parser/v2/ifexpressionparser_test.go
+++ b/parser/v2/ifexpressionparser_test.go
@@ -624,7 +624,14 @@ func TestIfExpression(t *testing.T) {
 				},
 				Then: []Node{
 					&Whitespace{Value: "\t\t"},
-					&GoComment{Contents: " this is a comment", Multiline: false},
+					&GoComment{
+						Contents:  " this is a comment",
+						Multiline: false,
+						Range: Range{
+							From: Position{Index: 11, Line: 1, Col: 2},
+							To:   Position{Index: 31, Line: 1, Col: 22},
+						},
+					},
 					&Whitespace{Value: "\n\t"},
 				},
 				Else: []Node{
diff --git a/parser/v2/templateparser_test.go b/parser/v2/templateparser_test.go
index 348bd16..08641f9 100644
--- a/parser/v2/templateparser_test.go
+++ b/parser/v2/templateparser_test.go
@@ -670,7 +670,14 @@ func TestTemplateParser(t *testing.T) {
 				},
 				Children: []Node{
 					&Whitespace{Value: "\t"},
-					&GoComment{Contents: " Comment", Multiline: false},
+					&GoComment{
+						Contents:  " Comment",
+						Multiline: false,
+						Range: Range{
+							From: Position{Index: 13, Line: 1, Col: 1},
+							To:   Position{Index: 23, Line: 1, Col: 11},
+						},
+					},
 					&Whitespace{Value: "\n"},
 				},
 			},
@@ -694,7 +701,14 @@ func TestTemplateParser(t *testing.T) {
 				},
 				Children: []Node{
 					&Whitespace{Value: "\t"},
-					&GoComment{Contents: " Comment ", Multiline: true},
+					&GoComment{
+						Contents:  " Comment ",
+						Multiline: true,
+						Range: Range{
+							From: Position{Index: 13, Line: 1, Col: 1},
+							To:   Position{Index: 26, Line: 1, Col: 14},
+						},
+					},
 					&Whitespace{Value: "\n"},
 				},
 			},
@@ -720,7 +734,14 @@ func TestTemplateParser(t *testing.T) {
 				},
 				Children: []Node{
 					&Whitespace{Value: "\t"},
-					&GoComment{Contents: " Line 1\n\t\t Line 2\n\t", Multiline: true},
+					&GoComment{
+						Contents:  " Line 1\n\t\t Line 2\n\t",
+						Multiline: true,
+						Range: Range{
+							From: Position{Index: 13, Line: 1, Col: 1},
+							To:   Position{Index: 36, Line: 3, Col: 3},
+						},
+					},
 					&Whitespace{Value: "\n"},
 				},
 			},