diff --git a/framework/h/cache.go b/framework/h/cache.go index 0c02056..6209388 100644 --- a/framework/h/cache.go +++ b/framework/h/cache.go @@ -7,17 +7,30 @@ import ( type CachedNode struct { cb func() *Element + isByKey bool + byKeyCache map[any]*Entry mutex sync.Mutex expiration time.Time html string } +type Entry struct { + expiration time.Time + html string +} + type GetElementFunc func() *Element type GetElementFuncT[T any] func(T) *Element type GetElementFuncT2[T any, T2 any] func(T, T2) *Element type GetElementFuncT3[T any, T2 any, T3 any] func(T, T2, T3) *Element type GetElementFuncT4[T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) *Element +type GetElementFuncWithKey[K comparable] func() (K, GetElementFunc) +type GetElementFuncTWithKey[K comparable, T any] func(T) (K, GetElementFunc) +type GetElementFuncT2WithKey[K comparable, T any, T2 any] func(T, T2) (K, GetElementFunc) +type GetElementFuncT3WithKey[K comparable, T any, T2 any, T3 any] func(T, T2, T3) (K, GetElementFunc) +type GetElementFuncT4WithKey[K comparable, T any, T2 any, T3 any, T4 any] func(T, T2, T3, T4) (K, GetElementFunc) + func Cached(duration time.Duration, cb GetElementFunc) func() *Element { element := &Element{ tag: CachedNodeTag, @@ -32,6 +45,127 @@ func Cached(duration time.Duration, cb GetElementFunc) func() *Element { } } +func CachedPerKey[K comparable](duration time.Duration, cb GetElementFuncWithKey[K]) func() *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func() *Element { + key, componentFunc := cb() + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +type ByKeyEntry struct { + key any + cb func() *Element + parent *Element +} + +func CachedPerKeyT[K comparable, T any](duration time.Duration, cb GetElementFuncTWithKey[K, T]) func(T) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T) *Element { + key, componentFunc := cb(data) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT2[K comparable, T any, T2 any](duration time.Duration, cb GetElementFuncT2WithKey[K, T, T2]) func(T, T2) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2) *Element { + key, componentFunc := cb(data, data2) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT3[K comparable, T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3WithKey[K, T, T2, T3]) func(T, T2, T3) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2, data3 T3) *Element { + key, componentFunc := cb(data, data2, data3) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + +func CachedPerKeyT4[K comparable, T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4WithKey[K, T, T2, T3, T4]) func(T, T2, T3, T4) *Element { + element := &Element{ + tag: CachedNodeTag, + meta: &CachedNode{ + isByKey: true, + cb: nil, + html: "", + expiration: time.Now().Add(duration), + }, + } + return func(data T, data2 T2, data3 T3, data4 T4) *Element { + key, componentFunc := cb(data, data2, data3, data4) + return &Element{ + tag: CachedNodeByKeyEntry, + meta: &ByKeyEntry{ + key: key, + parent: element, + cb: componentFunc, + }, + } + } +} + func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element { element := &Element{ tag: CachedNodeTag, @@ -102,16 +236,57 @@ func (c *CachedNode) ClearCache() { } func (c *CachedNode) Render(ctx *RenderContext) { - c.mutex.Lock() - if c.expiration.Before(time.Now()) { - c.html = "" + if c.isByKey == true { + panic("CachedPerKey should not be rendered directly") + } else { + c.mutex.Lock() + defer c.mutex.Unlock() + if c.expiration.Before(time.Now()) { + c.html = "" + } + + if c.html != "" { + ctx.builder.WriteString(c.html) + } else { + c.html = Render(c.cb()) + ctx.builder.WriteString(c.html) + } + } +} + +func (c *ByKeyEntry) Render(ctx *RenderContext) { + key := c.key + parentMeta := c.parent.meta.(*CachedNode) + + if parentMeta.byKeyCache == nil { + parentMeta.byKeyCache = make(map[any]*Entry) } - if c.html != "" { - ctx.builder.WriteString(c.html) - } else { - c.html = Render(c.cb()) - ctx.builder.WriteString(c.html) + entry := parentMeta.byKeyCache[key] + + var setAndWrite = func() { + html := Render(c.cb()) + parentMeta.byKeyCache[key] = &Entry{ + expiration: parentMeta.expiration, + html: html, + } + ctx.builder.WriteString(html) } - c.mutex.Unlock() + + // exists in cache but expired + if entry != nil && entry.expiration.Before(time.Now()) { + delete(parentMeta.byKeyCache, key) + setAndWrite() + return + } + + // not in cache + if entry == nil { + setAndWrite() + return + } + + // exists in cache and not expired + ctx.builder.WriteString(entry.html) + } diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 926c485..b9ef912 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -183,6 +183,190 @@ func TestCacheMultiple(t *testing.T) { assert.Equal(t, 1, count) } +func TestCacheByKey(t *testing.T) { + t.Parallel() + renderCount := 0 + callCount := 0 + cachedItem := CachedPerKey(time.Hour, func() (any, GetElementFunc) { + key := "key" + if callCount == 3 { + key = "key2" + } + if callCount == 4 { + key = "key" + } + callCount++ + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem(), + cachedItem(), + cachedItem(), + cachedItem(), + cachedItem(), + )) + + assert.Equal(t, 5, callCount) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Hour, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one"), + cachedItem("one"), + cachedItem("two"), + cachedItem("two"), + cachedItem("three"), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT4(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT4(time.Hour, func(arg1 string, arg2 string, arg3 string, arg4 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("one", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString(), uuid.NewString()), + cachedItem("three", uuid.NewString(), uuid.NewString(), uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT3(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT3(time.Hour, func(arg1 string, arg2 string, arg3 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString(), uuid.NewString()), + cachedItem("one", uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString()), + cachedItem("two", uuid.NewString(), uuid.NewString()), + cachedItem("three", uuid.NewString(), uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyT2(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT2(time.Hour, func(arg1 string, arg2 string) (any, GetElementFunc) { + return arg1, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + Render(Div( + cachedItem("one", uuid.NewString()), + cachedItem("one", uuid.NewString()), + cachedItem("two", uuid.NewString()), + cachedItem("two", uuid.NewString()), + cachedItem("three", uuid.NewString()), + )) + + assert.Equal(t, 3, renderCount) +} + +func TestCacheByKeyConcurrent(t *testing.T) { + t.Parallel() + renderCount := 0 + callCount := 0 + cachedItem := CachedPerKey(time.Hour, func() (any, GetElementFunc) { + key := "key" + if callCount == 3 { + key = "key2" + } + if callCount == 4 { + key = "key" + } + callCount++ + return key, func() *Element { + renderCount++ + return Div(Text("hello")) + } + }) + + wg := sync.WaitGroup{} + for i := 0; i < 5; i++ { + wg.Add(1) + go func() { + defer wg.Done() + Render(Div( + cachedItem(), + )) + }() + } + + wg.Wait() + + assert.Equal(t, 5, callCount) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT1_2(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Hour, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Pf(key) + } + }) + + assert.Equal(t, "
one
", Render(cachedItem("one"))) + assert.Equal(t, "two
", Render(cachedItem("two"))) + assert.Equal(t, "two
", Render(cachedItem("two"))) + assert.Equal(t, 2, renderCount) +} + +func TestCacheByKeyT1Expired(t *testing.T) { + t.Parallel() + renderCount := 0 + cachedItem := CachedPerKeyT(time.Millisecond, func(key string) (any, GetElementFunc) { + return key, func() *Element { + renderCount++ + return Pf(key) + } + }) + + assert.Equal(t, "one
", Render(cachedItem("one"))) + assert.Equal(t, "two
", Render(cachedItem("two"))) + time.Sleep(time.Millisecond * 2) + assert.Equal(t, "two
", Render(cachedItem("two"))) + assert.Equal(t, 3, renderCount) +} + func BenchmarkMailToStatic(b *testing.B) { b.ReportAllocs() ctx := RenderContext{ diff --git a/framework/h/renderer.go b/framework/h/renderer.go index 79877fd..f29c4db 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -10,7 +10,8 @@ import ( type CustomElement = string var ( - CachedNodeTag CustomElement = "htmgo_cache_node" + CachedNodeTag CustomElement = "htmgo_cache_node" + CachedNodeByKeyEntry CustomElement = "htmgo_cached_node_by_key_entry" ) /* @@ -57,6 +58,12 @@ func (node *Element) Render(context *RenderContext) { return } + if node.tag == CachedNodeByKeyEntry { + meta := node.meta.(*ByKeyEntry) + meta.Render(context) + return + } + if node.tag != "" { context.builder.WriteString("<") context.builder.WriteString(node.tag) diff --git a/htmgo-site/md/docs/5_performance/1_caching.md b/htmgo-site/md/docs/5_performance/1_caching_globally.md similarity index 72% rename from htmgo-site/md/docs/5_performance/1_caching.md rename to htmgo-site/md/docs/5_performance/1_caching_globally.md index 7ab156a..8ba3258 100644 --- a/htmgo-site/md/docs/5_performance/1_caching.md +++ b/htmgo-site/md/docs/5_performance/1_caching_globally.md @@ -1,4 +1,4 @@ -**Caching Components** +**Caching Components Globally** You may want to cache components to improve performance. This is especially useful for components that are expensive to render or make external requests for data. @@ -17,6 +17,9 @@ h.CachedT3(duration time.Duration, cb GetElementFunc) // Four arguments passed to the component h.CachedT4(duration time.Duration, cb GetElementFunc) ``` +For caching components per user, see [Caching Components Per User](#performance-caching-per-user). + +