From 1dfb32a36f57d562125973320659f3eda91e1400 Mon Sep 17 00:00:00 2001 From: silverwind Date: Tue, 10 Mar 2026 06:26:16 +0100 Subject: [PATCH] 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) + } + }) +}