commit 4d5ac0f58f908dcda8320de4f335af448f49257e
parent 5cf9620370532ec0a210de7771fdbb108c68e011
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 18:39:13 +0000
fix(web): load the first project's readme on a cold cache
/projects rendered projects[0] straight from the list cache, which is bare after a deploy or TTL expiry, and the browser then cached "README not available" for an hour. Resolve it via GetProjectByID, keep the last good READMEs across cache expiry, and never cache a detail without a README.
Diffstat:
3 files changed, 57 insertions(+), 7 deletions(-)
diff --git a/cmd/server/handlers/projects.go b/cmd/server/handlers/projects.go
@@ -30,15 +30,21 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
if id == "" {
if len(projects) > 0 {
- applyProjectDetailData(&data, &projects[0])
+ // Resolve through GetProjectByID so a cold cache fetches the
+ // README synchronously instead of rendering "not available".
+ first := &projects[0]
+ if p, err := content.GetProjectByID(h.gitService, first.ID); err == nil && p != nil {
+ first = p
+ }
+ applyProjectDetailData(&data, first)
}
if utils.IsHTMXRequest(r) {
- h.renderTemplate(w, r, "projects", data, fragmentCacheMaxAgeSeconds)
+ h.renderTemplate(w, r, "projects", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds))
return
}
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+ h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds))
return
}
@@ -55,7 +61,16 @@ func (h *Handler) ProjectsPageHandler(w http.ResponseWriter, r *http.Request) {
data.SelectedProjectID = id
applyProjectDetailData(&data, project)
- h.renderTemplate(w, r, "layout", data, fullPageCacheMaxAgeSeconds)
+ h.renderTemplate(w, r, "layout", data, detailMaxAge(data, fullPageCacheMaxAgeSeconds))
+}
+
+// detailMaxAge never lets a browser cache a page whose README failed to
+// load, so a transient fetch error isn't pinned for an hour.
+func detailMaxAge(data web.Data, maxAge int) int {
+ if data.Project != nil && strings.TrimSpace(string(data.ProjectReadme)) == "" {
+ return 0
+ }
+ return maxAge
}
func applyProjectDetailData(data *web.Data, project *config.Project) {
@@ -84,7 +99,7 @@ func (h *Handler) ProjectDetailHandler(w http.ResponseWriter, r *http.Request) {
ProjectReadme: template.HTML(project.HTML),
}
- h.renderTemplate(w, r, "project_detail", data, fragmentCacheMaxAgeSeconds)
+ h.renderTemplate(w, r, "project_detail", data, detailMaxAge(data, fragmentCacheMaxAgeSeconds))
}
// ProjectRawHandler serves a file from HEAD of a public project's repo, so
diff --git a/cmd/server/handlers/render.go b/cmd/server/handlers/render.go
@@ -27,7 +27,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name st
cacheEnabled := shouldCacheResponses()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if !cacheEnabled {
+ if !cacheEnabled || maxAgeSeconds == 0 {
applyNoCacheHeaders(w)
} else if maxAgeSeconds > 0 {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAgeSeconds))
diff --git a/internal/content/projects.go b/internal/content/projects.go
@@ -36,7 +36,7 @@ func LoadProjects(gitService *external.Git) ([]config.Project, error) {
}
}
- projects := config.GetProjects()
+ projects := withLastGoodReadmes(config.GetProjects())
projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
startProjectsRefresh(gitService)
@@ -62,6 +62,7 @@ func GetProjectByID(gitService *external.Git, id string) (*config.Project, error
if readme, err := fetchProjectReadme(gitService, projects[i]); err == nil {
projects[i].Markdown = readme.Markdown
projects[i].HTML = readme.HTML
+ rememberReadmes(projects[i : i+1])
projectsCache.Set(projectsCacheKey, cloneProjects(projects), projectsCacheTTL)
}
}
@@ -160,6 +161,40 @@ func startProjectsRefresh(gitService *external.Git) {
return
}
+ projects = withLastGoodReadmes(projects)
+ rememberReadmes(projects)
projectsCache.Set(projectsCacheKey, projects, projectsCacheTTL)
}()
}
+
+// lastGoodReadmes outlives the projects cache, so an expired cache or a failed
+// refresh serves the previous README instead of "README not available".
+var (
+ lastGoodMu sync.RWMutex
+ lastGoodReadmes = map[string]config.Project{}
+)
+
+func rememberReadmes(projects []config.Project) {
+ lastGoodMu.Lock()
+ defer lastGoodMu.Unlock()
+ for _, p := range projects {
+ if strings.TrimSpace(p.HTML) != "" {
+ lastGoodReadmes[p.ID] = config.Project{Markdown: p.Markdown, HTML: p.HTML}
+ }
+ }
+}
+
+func withLastGoodReadmes(projects []config.Project) []config.Project {
+ lastGoodMu.RLock()
+ defer lastGoodMu.RUnlock()
+ for i := range projects {
+ if strings.TrimSpace(projects[i].HTML) != "" {
+ continue
+ }
+ if good, ok := lastGoodReadmes[projects[i].ID]; ok {
+ projects[i].Markdown = good.Markdown
+ projects[i].HTML = good.HTML
+ }
+ }
+ return projects
+}