Skip to content

fix(http): restore web UI yield for layout templates (#101)#107

Open
renecannao wants to merge 1 commit into
masterfrom
fix/issue-101-web-ui-yield
Open

fix(http): restore web UI yield for layout templates (#101)#107
renecannao wants to merge 1 commit into
masterfrom
fix/issue-101-web-ui-yield

Conversation

@renecannao

@renecannao renecannao commented Jul 16, 2026

Copy link
Copy Markdown

Summary

  • Fixes every /web/* route returning HTTP 500 after fresh install (function "yield" not defined) — closes UI not working after fresh install #101
  • Restores the martini-contrib/render {{yield}} contract in renderHTML using stdlib html/template
  • Adds unit tests so this regression is caught in CI (script/test-unit)

Root cause

go/http/render.go was rewritten to use Go's html/template during the martini→chi migration, but resources/templates/layout.tmpl still calls {{yield}}. That function was previously provided by martini-contrib/render and was never re-registered.

Fix

  1. Execute the content template into a buffer
  2. Parse the layout with a per-request yield FuncMap that injects that HTML
  3. Execute the layout (content templates remain cached; layout parse is per-request for concurrency safety)

Test plan

  • go test ./go/http/ -run 'TestRenderHTML|TestLayoutRequiresYield' -v
  • All discovered content templates under resources/templates/ render with layout wrapper
  • TestLayoutRequiresYield fails parse without yield FuncMap and succeeds with it
  • Manual: ./orchestrator --config=... http then open /web/clusters, /web/home, /web/about — expect 200 HTML, not 500

Preventing recurrence

Unit tests (run by CI script/test-unit) now:

  1. Render every content template discovered from disk (not a hardcoded list), so new templates are covered automatically
  2. Assert layout still uses {{yield}} and that parsing layout without the FuncMap fails with the exact error from UI not working after fresh install #101
  3. Assert successful render injects content into the layout (doctype + page body)

No full browser/e2e suite is required for this class of bug — template parse/execute coverage is enough and fast.

Summary by CodeRabbit

  • Bug Fixes

    • Improved HTML page rendering to ensure shared layouts and page content are combined reliably.
    • Reduced the risk of rendering failures across web pages, with errors continuing to display safely.
  • Tests

    • Added coverage for layout rendering, required layout content insertion, and all available web templates.

After the martini→chi migration, renderHTML used stdlib html/template
without registering the yield FuncMap that layout.tmpl expects, so every
/web/* route returned 500.

Render content first, inject it via yield into the layout, and add unit
tests that cover all templates and guard the yield contract.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

HTML rendering now caches content templates separately, loads the shared layout source once, and injects buffered content per request. New tests validate yield registration, representative output, and rendering across all web templates.

Changes

HTML rendering

Layer / File(s) Summary
Buffered content and layout rendering
go/http/render.go
Content templates are cached independently, rendered into a buffer, and inserted into a layout parsed with a request-local yield function; layout loading is guarded by sync.Once.
Template rendering validation
go/http/render_test.go
Tests validate repository setup, cache isolation, yield requirements, representative rendering, and layout wrapping for all discovered web templates.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant renderHTML
  participant ContentTemplateCache
  participant LayoutSource
  participant HTTPResponse
  renderHTML->>ContentTemplateCache: getContentTemplate(name)
  ContentTemplateCache-->>renderHTML: content template
  renderHTML->>renderHTML: render content into buffer
  renderHTML->>LayoutSource: load layout source once
  LayoutSource-->>renderHTML: layout template source
  renderHTML->>HTTPResponse: execute layout with buffered yield content
Loading

Poem

A rabbit hops through templates bright,
Buffers content, then wraps it right.
One layout loads beneath the moon,
Yield fills each page in tune.
Tests thump softly: all is well!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main fix: restoring the layout yield behavior for web UI templates.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-101-web-ui-yield

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors HTML rendering in go/http/render.go to support dynamic content injection into a layout template via a {{yield}} function, and introduces a comprehensive test suite in go/http/render_test.go. The feedback suggests converting templateDir to a variable so tests can override it instead of modifying the global working directory with os.Chdir. Additionally, it is recommended to execute the layout template into a temporary buffer first to prevent sending partial HTTP responses in case of execution failures.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread go/http/render.go
layoutLoadErr error
)

const templateDir = "resources"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Changing templateDir from a const to a var allows tests to override it with an absolute path, avoiding the need to use os.Chdir in tests (which is not thread-safe and can cause flaky tests).

Suggested change
const templateDir = "resources"
var templateDir = "resources"

Comment thread go/http/render.go
Comment on lines 124 to 128
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.WriteHeader(status)
if err := t.Execute(w, data); err != nil {
_ = log.Errorf("Error executing template %s: %+v", name, err)
if err := layout.Execute(w, data); err != nil {
_ = log.Errorf("Error executing layout for template %s: %+v", name, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Executing the layout template directly to w after writing the status header can result in a partial/broken 200 OK response if a template execution error occurs midway.

To ensure robust error handling, consider executing the layout template into a temporary buffer first. If execution succeeds, write the headers and the buffer to the response; if it fails, you can safely return a 500 Internal Server Error.

Suggested change
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.WriteHeader(status)
if err := t.Execute(w, data); err != nil {
_ = log.Errorf("Error executing template %s: %+v", name, err)
if err := layout.Execute(w, data); err != nil {
_ = log.Errorf("Error executing layout for template %s: %+v", name, err)
}
var buf bytes.Buffer
if err := layout.Execute(&buf, data); err != nil {
_ = log.Errorf("Error executing layout for template %s: %+v", name, err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)

Comment thread go/http/render_test.go
Comment on lines +31 to +52
func chdirToRepoRoot(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
dir := wd
for {
if _, err := os.Stat(filepath.Join(dir, "resources", "templates", "layout.tmpl")); err == nil {
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(wd) })
return
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("could not find repo root containing resources/templates/layout.tmpl")
}
dir = parent
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using os.Chdir in tests changes the working directory of the entire process, which is a global state. This can cause race conditions and flaky test failures if other tests run concurrently or in parallel.

Instead of changing the working directory, we can find the repository root and override the package-level templateDir variable (after changing it to a var in render.go). Note that you will also need to update the calls to chdirToRepoRoot(t) to setTemplateDirToRepoRoot(t).

// setTemplateDirToRepoRoot finds the repository root and overrides templateDir
// so template paths resolve during tests without changing the process's working directory.
func setTemplateDirToRepoRoot(t *testing.T) {
	t.Helper()
	wd, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	dir := wd
	for {
		resourcesPath := filepath.Join(dir, "resources")
		if _, err := os.Stat(filepath.Join(resourcesPath, "templates", "layout.tmpl")); err == nil {
			oldTemplateDir := templateDir
			templateDir = resourcesPath
			t.Cleanup(func() { templateDir = oldTemplateDir })
			return
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			t.Fatal("could not find repo root containing resources/templates/layout.tmpl")
		}
		dir = parent
	}
}

Comment thread go/http/render_test.go
// (everything except layout). Discovered from disk so new templates are covered.
func contentTemplateNames(t *testing.T) []string {
t.Helper()
matches, err := filepath.Glob(filepath.Join("resources", "templates", "*.tmpl"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support running tests without changing the global working directory, use templateDir instead of the hardcoded "resources" string when locating templates.

Suggested change
matches, err := filepath.Glob(filepath.Join("resources", "templates", "*.tmpl"))
matches, err := filepath.Glob(filepath.Join(templateDir, "templates", "*.tmpl"))

Comment thread go/http/render_test.go
}

func TestRenderHTMLYield(t *testing.T) {
chdirToRepoRoot(t)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update to use setTemplateDirToRepoRoot(t) to avoid changing the global working directory.

Suggested change
chdirToRepoRoot(t)
setTemplateDirToRepoRoot(t)

Comment thread go/http/render_test.go
// TestLayoutRequiresYield guards the martini-contrib/render contract: layout.tmpl
// uses {{yield}} to inject page content. Parsing layout without that FuncMap must fail.
func TestLayoutRequiresYield(t *testing.T) {
chdirToRepoRoot(t)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update to use setTemplateDirToRepoRoot(t) to avoid changing the global working directory.

Suggested change
chdirToRepoRoot(t)
setTemplateDirToRepoRoot(t)

Comment thread go/http/render_test.go
func TestLayoutRequiresYield(t *testing.T) {
chdirToRepoRoot(t)

layoutPath := filepath.Join("resources", "templates", "layout.tmpl")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use templateDir instead of the hardcoded "resources" string to ensure the layout path resolves correctly when templateDir is overridden.

Suggested change
layoutPath := filepath.Join("resources", "templates", "layout.tmpl")
layoutPath := filepath.Join(templateDir, "templates", "layout.tmpl")

Comment thread go/http/render_test.go
}

func TestRenderHTMLAllWebTemplates(t *testing.T) {
chdirToRepoRoot(t)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update to use setTemplateDirToRepoRoot(t) to avoid changing the global working directory.

Suggested change
chdirToRepoRoot(t)
setTemplateDirToRepoRoot(t)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
go/http/render_test.go (1)

29-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid modifying the process-wide working directory in tests.

os.Chdir changes the working directory globally for the entire test process. If t.Parallel() is ever introduced to this package, concurrent tests will experience unpredictable file lookup failures when the directory changes underneath them.

Consider refactoring templateDir in render.go from a const to a var. This would allow tests to override the base path securely without mutating global process state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/http/render_test.go` around lines 29 - 52, Refactor the test setup around
chdirToRepoRoot and templateDir to avoid calling os.Chdir or restoring the
process-wide working directory. Change templateDir in render.go to an
overridable variable, and have tests temporarily assign the repository’s
template path with cleanup that restores the original variable value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@go/http/render.go`:
- Around line 67-83: The template loading flow should avoid duplicate parsing
when concurrent requests miss the cache. In the cache-miss path around
contentTemplateCache, acquire the write lock before parsing, recheck
contentTemplateCache.m[name], return the existing template if found, otherwise
parse and cache the template while holding the lock, then unlock on every return
path.

---

Nitpick comments:
In `@go/http/render_test.go`:
- Around line 29-52: Refactor the test setup around chdirToRepoRoot and
templateDir to avoid calling os.Chdir or restoring the process-wide working
directory. Change templateDir in render.go to an overridable variable, and have
tests temporarily assign the repository’s template path with cleanup that
restores the original variable value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 59d9d436-ffb1-4a38-9ce5-10cdd578d5bd

📥 Commits

Reviewing files that changed from the base of the PR and between 4457c7a and bc972c7.

📒 Files selected for processing (2)
  • go/http/render.go
  • go/http/render_test.go

Comment thread go/http/render.go
Comment on lines +67 to 83
contentTemplateCache.RLock()
if t, ok := contentTemplateCache.m[name]; ok {
contentTemplateCache.RUnlock()
return t, nil
}
templateCache.RUnlock()
contentTemplateCache.RUnlock()

layoutPath := filepath.Join(templateDir, layoutFile+".tmpl")
tmplPath := filepath.Join(templateDir, name+".tmpl")
t, err := template.ParseFiles(layoutPath, tmplPath)
t, err := template.ParseFiles(tmplPath)
if err != nil {
return nil, err
}

templateCache.Lock()
templateCache.m[name] = t
templateCache.Unlock()
contentTemplateCache.Lock()
contentTemplateCache.m[name] = t
contentTemplateCache.Unlock()
return t, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Fix TOCTOU race condition in template caching.

If multiple requests concurrently request the same un-cached template, they will all bypass the read lock and perform duplicate disk I/O and parsing. They will then sequentially acquire the write lock and overwrite the cache entry.

Move the parsing logic inside the write lock and add a second check to complete the double-checked locking pattern.

🛠️ Proposed fix
 	contentTemplateCache.RLock()
 	if t, ok := contentTemplateCache.m[name]; ok {
 		contentTemplateCache.RUnlock()
 		return t, nil
 	}
 	contentTemplateCache.RUnlock()
 
+	contentTemplateCache.Lock()
+	defer contentTemplateCache.Unlock()
+
+	if t, ok := contentTemplateCache.m[name]; ok {
+		return t, nil
+	}
+
 	tmplPath := filepath.Join(templateDir, name+".tmpl")
 	t, err := template.ParseFiles(tmplPath)
 	if err != nil {
 		return nil, err
 	}
 
-	contentTemplateCache.Lock()
 	contentTemplateCache.m[name] = t
-	contentTemplateCache.Unlock()
 	return t, nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
contentTemplateCache.RLock()
if t, ok := contentTemplateCache.m[name]; ok {
contentTemplateCache.RUnlock()
return t, nil
}
templateCache.RUnlock()
contentTemplateCache.RUnlock()
layoutPath := filepath.Join(templateDir, layoutFile+".tmpl")
tmplPath := filepath.Join(templateDir, name+".tmpl")
t, err := template.ParseFiles(layoutPath, tmplPath)
t, err := template.ParseFiles(tmplPath)
if err != nil {
return nil, err
}
templateCache.Lock()
templateCache.m[name] = t
templateCache.Unlock()
contentTemplateCache.Lock()
contentTemplateCache.m[name] = t
contentTemplateCache.Unlock()
return t, nil
contentTemplateCache.RLock()
if t, ok := contentTemplateCache.m[name]; ok {
contentTemplateCache.RUnlock()
return t, nil
}
contentTemplateCache.RUnlock()
contentTemplateCache.Lock()
defer contentTemplateCache.Unlock()
if t, ok := contentTemplateCache.m[name]; ok {
return t, nil
}
tmplPath := filepath.Join(templateDir, name+".tmpl")
t, err := template.ParseFiles(tmplPath)
if err != nil {
return nil, err
}
contentTemplateCache.m[name] = t
return t, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go/http/render.go` around lines 67 - 83, The template loading flow should
avoid duplicate parsing when concurrent requests miss the cache. In the
cache-miss path around contentTemplateCache, acquire the write lock before
parsing, recheck contentTemplateCache.m[name], return the existing template if
found, otherwise parse and cache the template while holding the lock, then
unlock on every return path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI not working after fresh install

1 participant