From 1dfb32a36f57d562125973320659f3eda91e1400 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 06:26:16 +0100 Subject: [PATCH 1/2] Add render cache for SVG icons (#36863) Cache the final rendered `template.HTML` output for SVG icons that use non-default size or class parameters using `sync.Map`. Co-authored-by: Claude (Opus 4.6) Co-authored-by: wxiaoguang --- modules/svg/svg.go | 73 ++++++++++++++++++++++++++++++++++++----- modules/svg/svg_test.go | 54 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 modules/svg/svg_test.go diff --git a/modules/svg/svg.go b/modules/svg/svg.go index 333b5764c2..234b1f8c13 100644 --- a/modules/svg/svg.go +++ b/modules/svg/svg.go @@ -8,13 +8,32 @@ import ( "html/template" "path" "strings" + "sync" gitea_html "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/public" ) -var svgIcons map[string]string +type svgIconItem struct { + html string + mocking bool +} + +type svgCacheKey struct { + icon string + size int + class string +} + +var ( + svgIcons map[string]svgIconItem + + svgCacheMu sync.Mutex + svgCache sync.Map + svgCacheCount int + svgCacheLimit = 10000 +) const defaultSize = 16 @@ -26,7 +45,7 @@ func Init() error { return err } - svgIcons = make(map[string]string, len(files)) + svgIcons = make(map[string]svgIconItem, len(files)) for _, file := range files { if path.Ext(file) != ".svg" { continue @@ -35,7 +54,7 @@ func Init() error { if err != nil { log.Error("Failed to read SVG file %s: %v", file, err) } else { - svgIcons[file[:len(file)-4]] = string(Normalize(bs, defaultSize)) + svgIcons[file[:len(file)-4]] = svgIconItem{html: string(Normalize(bs, defaultSize))} } } return nil @@ -43,10 +62,13 @@ func Init() error { func MockIcon(icon string) func() { if svgIcons == nil { - svgIcons = make(map[string]string) + svgIcons = make(map[string]svgIconItem) } orig, exist := svgIcons[icon] - svgIcons[icon] = fmt.Sprintf(``, icon, defaultSize, defaultSize) + svgIcons[icon] = svgIconItem{ + html: fmt.Sprintf(``, icon, defaultSize, defaultSize), + mocking: true, + } return func() { if exist { svgIcons[icon] = orig @@ -58,11 +80,28 @@ func MockIcon(icon string) func() { // RenderHTML renders icons - arguments icon name (string), size (int), class (string) func RenderHTML(icon string, others ...any) template.HTML { + result, _ := renderHTML(icon, others...) + return result +} + +func renderHTML(icon string, others ...any) (_ template.HTML, usingCache bool) { if icon == "" { - return "" + return "", false } size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...) - if svgStr, ok := svgIcons[icon]; ok { + if svgItem, ok := svgIcons[icon]; ok { + svgStr := svgItem.html + // fast path for default size and no classes + if size == defaultSize && class == "" { + return template.HTML(svgStr), false + } + + cacheKey := svgCacheKey{icon, size, class} + cachedHTML, cached := svgCache.Load(cacheKey) + if cached && !svgItem.mocking { + return cachedHTML.(template.HTML), true + } + // the code is somewhat hacky, but it just works, because the SVG contents are all normalized if size != defaultSize { svgStr = strings.Replace(svgStr, fmt.Sprintf(`width="%d"`, defaultSize), fmt.Sprintf(`width="%d"`, size), 1) @@ -71,8 +110,24 @@ func RenderHTML(icon string, others ...any) template.HTML { if class != "" { svgStr = strings.Replace(svgStr, `class="`, fmt.Sprintf(`class="%s `, class), 1) } - return template.HTML(svgStr) + result := template.HTML(svgStr) + + if !svgItem.mocking { + // no need to double-check, the rendering is fast enough and the cache is just an optimization + svgCacheMu.Lock() + if svgCacheCount >= svgCacheLimit { + svgCache.Clear() + svgCacheCount = 0 + } + svgCacheCount++ + svgCache.Store(cacheKey, result) + svgCacheMu.Unlock() + } + + return result, false } + // during test (or something wrong happens), there is no SVG loaded, so use a dummy span to tell that the icon is missing - return template.HTML(fmt.Sprintf("%s(%d/%s)", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class))) + dummy := template.HTML(fmt.Sprintf("%s(%d/%s)", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class))) + return dummy, false } diff --git a/modules/svg/svg_test.go b/modules/svg/svg_test.go new file mode 100644 index 0000000000..a42f57cec6 --- /dev/null +++ b/modules/svg/svg_test.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package svg + +import ( + "testing" + + "code.gitea.io/gitea/modules/test" + + "github.com/stretchr/testify/assert" +) + +func TestRenderHTMLCache(t *testing.T) { + const svgRealContent = "RealContent" + svgIcons = map[string]svgIconItem{ + "test": {html: `` + svgRealContent + ``}, + } + + // default params: no cache entry + _, usingCache := renderHTML("test") + assert.False(t, usingCache) + _, usingCache = renderHTML("test") + assert.False(t, usingCache) + + // non-default params: cached + _, usingCache = renderHTML("test", 24) + assert.False(t, usingCache) + _, usingCache = renderHTML("test", 24) + assert.True(t, usingCache) + + // mocked svg shouldn't be cached + revertMock := MockIcon("test") + mockedHTML, usingCache := renderHTML("test", 24) + assert.False(t, usingCache) + assert.NotContains(t, mockedHTML, svgRealContent) + revertMock() + realHTML, usingCache := renderHTML("test", 24) + assert.True(t, usingCache) + assert.Contains(t, realHTML, svgRealContent) + + t.Run("CacheWithLimit", func(t *testing.T) { + assert.NotZero(t, svgCacheCount) + const testLimit = 3 + defer test.MockVariableValue(&svgCacheLimit, testLimit)() + for i := range 10 { + _, usingCache = renderHTML("test", 100+i) + assert.False(t, usingCache) + _, usingCache = renderHTML("test", 100+i) + assert.True(t, usingCache) + assert.LessOrEqual(t, svgCacheCount, testLimit) + } + }) +} From 8d06a9425e64f188e3c125afda00716e0eefd6ed Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 10 Mar 2026 07:26:52 +0100 Subject: [PATCH 2/2] Update minimum go version to 1.26.1, golangci-lint to 2.11.2, fix test style (#36876) Hey, I bumped Go to 1.26.1 and fixed a couple of things I ran into while poking around. ### Changes - Bump go.mod from 1.26.0 to 1.26.1 (security patch) - Bump golangci-lint from v2.10.1 to v2.11.2 - Run make tidy, fmt, lint-go --------- Co-authored-by: silverwind Co-authored-by: Claude (Opus 4.6) --- Makefile | 2 +- go.mod | 2 +- tests/integration/api_packages_conan_test.go | 7 ++++--- tests/integration/api_packages_generic_test.go | 7 ++++--- tests/integration/api_releases_test.go | 2 +- tests/integration/api_repo_get_contents_test.go | 2 +- tests/integration/api_token_test.go | 7 ++++--- tests/integration/pull_comment_test.go | 2 +- tests/integration/repo_test.go | 5 +++-- 9 files changed, 20 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 280aa853b8..393b27dc9e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ XGO_VERSION := go-1.25.x AIR_PACKAGE ?= github.com/air-verse/air@v1 EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.2 GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 diff --git a/go.mod b/go.mod index c85c4c0b39..f0bb361f2d 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.gitea.io/gitea -go 1.26.0 +go 1.26.1 // rfc5280 said: "The serial number is an integer assigned by the CA to each certificate." // But some CAs use negative serial number, just relax the check. related: diff --git a/tests/integration/api_packages_conan_test.go b/tests/integration/api_packages_conan_test.go index 2223b9bbac..ad8fcdc80c 100644 --- a/tests/integration/api_packages_conan_test.go +++ b/tests/integration/api_packages_conan_test.go @@ -346,15 +346,16 @@ func TestPackageConan(t *testing.T) { pb, err := packages.GetBlobByID(t.Context(), pf.BlobID) assert.NoError(t, err) - if pf.Name == conanfileName { + switch pf.Name { + case conanfileName: assert.True(t, pf.IsLead) assert.Equal(t, int64(len(buildConanfileContent(name, version1))), pb.Size) - } else if pf.Name == conaninfoName { + case conaninfoName: assert.False(t, pf.IsLead) assert.Equal(t, int64(len(contentConaninfo)), pb.Size) - } else { + default: assert.FailNow(t, "unknown file", "unknown file: %s", pf.Name) } } diff --git a/tests/integration/api_packages_generic_test.go b/tests/integration/api_packages_generic_test.go index 5e368967ee..c7eb4486d2 100644 --- a/tests/integration/api_packages_generic_test.go +++ b/tests/integration/api_packages_generic_test.go @@ -140,11 +140,12 @@ func TestPackageGeneric(t *testing.T) { t.Run("ServeDirect", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - if setting.Packages.Storage.Type == setting.MinioStorageType { + switch setting.Packages.Storage.Type { + case setting.MinioStorageType: defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, true)() - } else if setting.Packages.Storage.Type == setting.AzureBlobStorageType { + case setting.AzureBlobStorageType: defer test.MockVariableValue(&setting.Packages.Storage.AzureBlobConfig.ServeDirect, true)() - } else { + default: t.Skip("Test skipped for non-Minio-storage and non-AzureBlob-storage.") } diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index c7f1343dde..9a5bf2b437 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -422,7 +422,7 @@ func TestAPIUploadAssetRelease(t *testing.T) { defer tests.PrintCurrentTest(t)() const filename = "image.png" - performUpload := func(t *testing.T, uploadURL string, buf []byte, expectedStatus int) *httptest.ResponseRecorder { + performUpload := func(t *testing.T, uploadURL string, _ []byte, _ int) *httptest.ResponseRecorder { body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("attachment", filename) diff --git a/tests/integration/api_repo_get_contents_test.go b/tests/integration/api_repo_get_contents_test.go index 33960b1ea3..251816950b 100644 --- a/tests/integration/api_repo_get_contents_test.go +++ b/tests/integration/api_repo_get_contents_test.go @@ -60,7 +60,7 @@ func TestAPIGetContents(t *testing.T) { }) } -func testAPIGetContents(t *testing.T, u *url.URL) { +func testAPIGetContents(t *testing.T, _ *url.URL) { /*** SETUP ***/ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16 org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go index 75fef9cc4e..91c05e5085 100644 --- a/tests/integration/api_token_test.go +++ b/tests/integration/api_token_test.go @@ -502,11 +502,12 @@ func runTestCase(t *testing.T, testCase *requiredScopeTestCase, user *user_model } unauthorizedLevel := auth_model.Write if categoryIsRequired { - if minRequiredLevel == auth_model.Read { + switch minRequiredLevel { + case auth_model.Read: unauthorizedLevel = auth_model.NoAccess - } else if minRequiredLevel == auth_model.Write { + case auth_model.Write: unauthorizedLevel = auth_model.Read - } else { + default: assert.FailNow(t, "Invalid test case", "Unknown access token scope level: %v", minRequiredLevel) } } diff --git a/tests/integration/pull_comment_test.go b/tests/integration/pull_comment_test.go index cb4c70930b..56ef9972ad 100644 --- a/tests/integration/pull_comment_test.go +++ b/tests/integration/pull_comment_test.go @@ -74,7 +74,7 @@ func testPullCommentRebase(t *testing.T, u *url.URL, session *TestSession) { assert.True(t, lastComment.IsForcePush) } -func testPullCommentRetarget(t *testing.T, u *url.URL, session *TestSession) { +func testPullCommentRetarget(t *testing.T, _ *url.URL, session *TestSession) { testPRTitle := "Test PR for retarget comment" // keep a non-conflict branch testCreateBranch(t, session, "user2", "repo1", "branch/test-branch/retarget", "test-branch/retarget-no-conflict", http.StatusSeeOther) diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go index e88b6b6224..1841598285 100644 --- a/tests/integration/repo_test.go +++ b/tests/integration/repo_test.go @@ -92,9 +92,10 @@ func testViewRepoWithCache(t *testing.T) { tds := s.Find(".repo-file-cell") var f file tds.Each(func(i int, s *goquery.Selection) { - if i == 0 { + switch i { + case 0: f.fileName = strings.TrimSpace(s.Text()) - } else if i == 1 { + case 1: a := s.Find("a") f.commitMsg = strings.TrimSpace(a.Text()) l, _ := a.Attr("href")