From 63266ba0364cac82b1f2431c06881e0207a5e9b2 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 17 Feb 2026 22:46:42 +0100 Subject: [PATCH] Fix theme loading in development (#36605) Fixes: https://github.com/go-gitea/gitea/issues/36543 When running `make watch`, the backend may start before webpack finishes building CSS theme files. Since themes were loaded once via sync.Once, they would never reload, breaking the theme selector and showing a persistent error on the admin page. In dev mode, themes are now reloaded from disk on each access so they become available as soon as webpack finishes. Production behavior is unchanged where themes are loaded once and cached via sync.Once. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: wxiaoguang --- services/webtheme/webtheme.go | 101 +++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 8091c25713..a0beec2902 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -16,10 +16,14 @@ import ( "code.gitea.io/gitea/modules/util" ) +type themeCollection struct { + themeList []*ThemeMetaInfo + themeMap map[string]*ThemeMetaInfo +} + var ( - availableThemes []*ThemeMetaInfo - availableThemeMap map[string]*ThemeMetaInfo - themeOnce sync.Once + themeMu sync.RWMutex + availableThemes *themeCollection ) const ( @@ -129,23 +133,13 @@ func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { return themeInfo } -func initThemes() { - availableThemes = nil - defer func() { - availableThemeMap = map[string]*ThemeMetaInfo{} - for _, theme := range availableThemes { - availableThemeMap[theme.InternalName] = theme - } - if availableThemeMap[setting.UI.DefaultTheme] == nil { - setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme) - } - }() +func loadThemesFromAssets() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { cssFiles, err := public.AssetFS().ListFiles("assets/css") if err != nil { log.Error("Failed to list themes: %v", err) - availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} - return + return nil, nil } + var foundThemes []*ThemeMetaInfo for _, fileName := range cssFiles { if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) { @@ -157,39 +151,84 @@ func initThemes() { foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content))) } } + + themeList = foundThemes if len(setting.UI.Themes) > 0 { + themeList = nil // only allow the themes specified in the setting allowedThemes := container.SetOf(setting.UI.Themes...) for _, theme := range foundThemes { if allowedThemes.Contains(theme.InternalName) { - availableThemes = append(availableThemes, theme) + themeList = append(themeList, theme) } } - } else { - availableThemes = foundThemes } - sort.Slice(availableThemes, func(i, j int) bool { - if availableThemes[i].InternalName == setting.UI.DefaultTheme { + + sort.Slice(themeList, func(i, j int) bool { + if themeList[i].InternalName == setting.UI.DefaultTheme { return true } - if availableThemes[i].ColorblindType != availableThemes[j].ColorblindType { - return availableThemes[i].ColorblindType < availableThemes[j].ColorblindType + if themeList[i].ColorblindType != themeList[j].ColorblindType { + return themeList[i].ColorblindType < themeList[j].ColorblindType } - return availableThemes[i].DisplayName < availableThemes[j].DisplayName + return themeList[i].DisplayName < themeList[j].DisplayName }) - if len(availableThemes) == 0 { - setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme") - availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)} + + themeMap = map[string]*ThemeMetaInfo{} + for _, theme := range themeList { + themeMap[theme.InternalName] = theme } + return themeList, themeMap +} + +func getAvailableThemes() (themeList []*ThemeMetaInfo, themeMap map[string]*ThemeMetaInfo) { + themeMu.RLock() + if availableThemes != nil { + themeList, themeMap = availableThemes.themeList, availableThemes.themeMap + } + themeMu.RUnlock() + if len(themeList) != 0 { + return themeList, themeMap + } + + themeMu.Lock() + defer themeMu.Unlock() + // no need to double-check "availableThemes.themeList" since the loading isn't really slow, to keep code simple + themeList, themeMap = loadThemesFromAssets() + hasAvailableThemes := len(themeList) > 0 + if !hasAvailableThemes { + defaultTheme := defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme) + themeList = []*ThemeMetaInfo{defaultTheme} + themeMap = map[string]*ThemeMetaInfo{setting.UI.DefaultTheme: defaultTheme} + } + + if setting.IsProd { + if !hasAvailableThemes { + setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme") + } + if themeMap[setting.UI.DefaultTheme] == nil { + setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme) + } + availableThemes = &themeCollection{themeList, themeMap} + return themeList, themeMap + } + + // In dev mode, only store the loaded themes if the list is not empty, in case the frontend is still being built. + // TBH, there still could be a data-race that the themes are only partially built then the list is incomplete for first time loading. + // Such edge case can be handled by checking whether the loaded themes are the same in a period or there is a flag file, but it is an over-kill, so, no. + if hasAvailableThemes { + availableThemes = &themeCollection{themeList, themeMap} + } + return themeList, themeMap } func GetAvailableThemes() []*ThemeMetaInfo { - themeOnce.Do(initThemes) - return availableThemes + themes, _ := getAvailableThemes() + return themes } func GetThemeMetaInfo(internalName string) *ThemeMetaInfo { - themeOnce.Do(initThemes) - return availableThemeMap[internalName] + _, themeMap := getAvailableThemes() + return themeMap[internalName] } // GuaranteeGetThemeMetaInfo guarantees to return a non-nil ThemeMetaInfo,