Skip to content

Commit 7ca540a

Browse files
silverwindclaude
andcommitted
Add render cache for SVG icons
Cache the final rendered template.HTML output for SVG icons that use non-default size or class parameters using sync.Map. Icons rendered with default parameters bypass the cache and use a direct read-only map lookup. Co-Authored-By: Claude (Opus 4.6) <noreply@anthropic.com>
1 parent 3f1ef70 commit 7ca540a

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

modules/svg/svg.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,33 @@ import (
88
"html/template"
99
"path"
1010
"strings"
11+
"sync"
12+
"sync/atomic"
1113

1214
gitea_html "code.gitea.io/gitea/modules/htmlutil"
1315
"code.gitea.io/gitea/modules/log"
1416
"code.gitea.io/gitea/modules/public"
1517
)
1618

17-
var svgIcons map[string]string
19+
type svgCacheKey struct {
20+
icon string
21+
size int
22+
class string
23+
}
24+
25+
var (
26+
svgIcons map[string]string
27+
svgRenderedHTMLCache sync.Map
28+
svgRenderedHTMLCacheSize atomic.Int32
29+
)
1830

1931
const defaultSize = 16
32+
const cacheLimit = 10000
33+
34+
func clearSVGRenderCache() {
35+
svgRenderedHTMLCache.Clear()
36+
svgRenderedHTMLCacheSize.Store(0)
37+
}
2038

2139
// Init discovers SVG icons and populates the `svgIcons` variable
2240
func Init() error {
@@ -26,6 +44,7 @@ func Init() error {
2644
return err
2745
}
2846

47+
clearSVGRenderCache()
2948
svgIcons = make(map[string]string, len(files))
3049
for _, file := range files {
3150
if path.Ext(file) != ".svg" {
@@ -45,9 +64,12 @@ func MockIcon(icon string) func() {
4564
if svgIcons == nil {
4665
svgIcons = make(map[string]string)
4766
}
67+
clearSVGRenderCache()
4868
orig, exist := svgIcons[icon]
4969
svgIcons[icon] = fmt.Sprintf(`<svg class="svg %s" width="%d" height="%d"></svg>`, icon, defaultSize, defaultSize)
5070
return func() {
71+
svgRenderedHTMLCache.Clear()
72+
svgRenderedHTMLCacheSize.Store(0)
5173
if exist {
5274
svgIcons[icon] = orig
5375
} else {
@@ -63,6 +85,13 @@ func RenderHTML(icon string, others ...any) template.HTML {
6385
}
6486
size, class := gitea_html.ParseSizeAndClass(defaultSize, "", others...)
6587
if svgStr, ok := svgIcons[icon]; ok {
88+
if size == defaultSize && class == "" {
89+
return template.HTML(svgStr)
90+
}
91+
cacheKey := svgCacheKey{icon, size, class}
92+
if v, ok := svgRenderedHTMLCache.Load(cacheKey); ok {
93+
return v.(template.HTML)
94+
}
6695
// the code is somewhat hacky, but it just works, because the SVG contents are all normalized
6796
if size != defaultSize {
6897
svgStr = strings.Replace(svgStr, fmt.Sprintf(`width="%d"`, defaultSize), fmt.Sprintf(`width="%d"`, size), 1)
@@ -71,7 +100,13 @@ func RenderHTML(icon string, others ...any) template.HTML {
71100
if class != "" {
72101
svgStr = strings.Replace(svgStr, `class="`, fmt.Sprintf(`class="%s `, class), 1)
73102
}
74-
return template.HTML(svgStr)
103+
result := template.HTML(svgStr)
104+
if svgRenderedHTMLCacheSize.Load() >= cacheLimit {
105+
clearSVGRenderCache()
106+
}
107+
svgRenderedHTMLCache.Store(cacheKey, result)
108+
svgRenderedHTMLCacheSize.Add(1)
109+
return result
75110
}
76111
// during test (or something wrong happens), there is no SVG loaded, so use a dummy span to tell that the icon is missing
77112
return template.HTML(fmt.Sprintf("<span>%s(%d/%s)</span>", template.HTMLEscapeString(icon), size, template.HTMLEscapeString(class)))

modules/svg/svg_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package svg
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestRenderHTMLCache(t *testing.T) {
13+
svgIcons = map[string]string{
14+
"test-icon": `<svg class="svg test-icon" width="16" height="16"><path/></svg>`,
15+
}
16+
svgRenderedHTMLCache.Clear()
17+
18+
// default size and no class: fast path, no cache entry
19+
result := RenderHTML("test-icon")
20+
assert.Equal(t, `<svg class="svg test-icon" width="16" height="16"><path/></svg>`, string(result))
21+
_, cached := svgRenderedHTMLCache.Load(svgCacheKey{"test-icon", 16, ""})
22+
assert.False(t, cached, "default params should not create a cache entry")
23+
24+
// custom size: should cache
25+
result = RenderHTML("test-icon", 24)
26+
assert.Contains(t, string(result), `width="24"`)
27+
assert.Contains(t, string(result), `height="24"`)
28+
_, cached = svgRenderedHTMLCache.Load(svgCacheKey{"test-icon", 24, ""})
29+
assert.True(t, cached, "custom size should create a cache entry")
30+
31+
// custom class: should cache
32+
result = RenderHTML("test-icon", 16, "extra")
33+
assert.Contains(t, string(result), `class="extra svg test-icon"`)
34+
_, cached = svgRenderedHTMLCache.Load(svgCacheKey{"test-icon", 16, "extra"})
35+
assert.True(t, cached, "custom class should create a cache entry")
36+
37+
// cache hit returns same result
38+
result2 := RenderHTML("test-icon", 24)
39+
assert.Equal(t, result2, RenderHTML("test-icon", 24))
40+
41+
// missing icon returns dummy span
42+
result = RenderHTML("nonexistent", 16, "cls")
43+
assert.Contains(t, string(result), "<span>")
44+
assert.Contains(t, string(result), "nonexistent")
45+
}
46+
47+
func TestMockIconClearsCache(t *testing.T) {
48+
svgIcons = map[string]string{
49+
"mock-icon": `<svg class="svg mock-icon" width="16" height="16"><path/></svg>`,
50+
}
51+
svgRenderedHTMLCache.Clear()
52+
53+
// populate cache
54+
RenderHTML("mock-icon", 24)
55+
_, cached := svgRenderedHTMLCache.Load(svgCacheKey{"mock-icon", 24, ""})
56+
assert.True(t, cached)
57+
58+
// MockIcon should clear cache
59+
restore := MockIcon("mock-icon")
60+
_, cached = svgRenderedHTMLCache.Load(svgCacheKey{"mock-icon", 24, ""})
61+
assert.False(t, cached, "MockIcon should clear cache")
62+
63+
// restore should also clear cache
64+
RenderHTML("mock-icon", 24)
65+
restore()
66+
_, cached = svgRenderedHTMLCache.Load(svgCacheKey{"mock-icon", 24, ""})
67+
assert.False(t, cached, "restore should clear cache")
68+
}

0 commit comments

Comments
 (0)