diff --git a/framework/h/cache.go b/framework/h/cache.go
new file mode 100644
index 0000000..0c02056
--- /dev/null
+++ b/framework/h/cache.go
@@ -0,0 +1,117 @@
+package h
+
+import (
+ "sync"
+ "time"
+)
+
+type CachedNode struct {
+ cb func() *Element
+ mutex sync.Mutex
+ 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
+
+func Cached(duration time.Duration, cb GetElementFunc) func() *Element {
+ element := &Element{
+ tag: CachedNodeTag,
+ meta: &CachedNode{
+ cb: cb,
+ html: "",
+ expiration: time.Now().Add(duration),
+ },
+ }
+ return func() *Element {
+ return element
+ }
+}
+
+func CachedT[T any](duration time.Duration, cb GetElementFuncT[T]) func(T) *Element {
+ element := &Element{
+ tag: CachedNodeTag,
+ meta: &CachedNode{
+ html: "",
+ expiration: time.Now().Add(duration),
+ mutex: sync.Mutex{},
+ },
+ }
+ return func(data T) *Element {
+ element.meta.(*CachedNode).cb = func() *Element {
+ return cb(data)
+ }
+ return element
+ }
+}
+
+func CachedT2[T any, T2 any](duration time.Duration, cb GetElementFuncT2[T, T2]) func(T, T2) *Element {
+ element := &Element{
+ tag: CachedNodeTag,
+ meta: &CachedNode{
+ html: "",
+ expiration: time.Now().Add(duration),
+ },
+ }
+ return func(data T, data2 T2) *Element {
+ element.meta.(*CachedNode).cb = func() *Element {
+ return cb(data, data2)
+ }
+ return element
+ }
+}
+
+func CachedT3[T any, T2 any, T3 any](duration time.Duration, cb GetElementFuncT3[T, T2, T3]) func(T, T2, T3) *Element {
+ element := &Element{
+ tag: CachedNodeTag,
+ meta: &CachedNode{
+ html: "",
+ expiration: time.Now().Add(duration),
+ },
+ }
+ return func(data T, data2 T2, data3 T3) *Element {
+ element.meta.(*CachedNode).cb = func() *Element {
+ return cb(data, data2, data3)
+ }
+ return element
+ }
+}
+
+func CachedT4[T any, T2 any, T3 any, T4 any](duration time.Duration, cb GetElementFuncT4[T, T2, T3, T4]) func(T, T2, T3, T4) *Element {
+ element := &Element{
+ tag: CachedNodeTag,
+ meta: &CachedNode{
+ html: "",
+ expiration: time.Now().Add(duration),
+ },
+ }
+ return func(data T, data2 T2, data3 T3, data4 T4) *Element {
+ element.meta.(*CachedNode).cb = func() *Element {
+ return cb(data, data2, data3, data4)
+ }
+ return element
+ }
+}
+
+func (c *CachedNode) ClearCache() {
+ c.html = ""
+}
+
+func (c *CachedNode) Render(ctx *RenderContext) {
+ c.mutex.Lock()
+ 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)
+ }
+ c.mutex.Unlock()
+}
diff --git a/framework/h/conditionals.go b/framework/h/conditionals.go
index 63b62fa..7705b26 100644
--- a/framework/h/conditionals.go
+++ b/framework/h/conditionals.go
@@ -20,6 +20,14 @@ func ElementIf(condition bool, element *Element) *Element {
}
}
+func IfElseE(condition bool, element *Element, element2 *Element) *Element {
+ if condition {
+ return element
+ } else {
+ return element2
+ }
+}
+
func IfElse[T any](condition bool, node T, node2 T) T {
if condition {
return node
diff --git a/framework/h/render_test.go b/framework/h/render_test.go
index 12464fe..926c485 100644
--- a/framework/h/render_test.go
+++ b/framework/h/render_test.go
@@ -7,7 +7,9 @@ import (
"golang.org/x/net/html"
"sort"
"strings"
+ "sync"
"testing"
+ "time"
)
// Sort attributes of a node by attribute name
@@ -28,7 +30,7 @@ func traverseAndSortAttributes(node *html.Node) {
}
// Parse HTML, sort attributes, and render back to a string
-func parseSortAndRenderHTML(input string) string {
+func sortHtmlAttributes(input string) string {
// Parse the HTML string into a node tree
doc, err := html.Parse(strings.NewReader(input))
if err != nil {
@@ -73,8 +75,8 @@ func TestRender(t *testing.T) {
div.attributes["data-attr-1"] = "value"
expectedRaw := `
`
- expected := parseSortAndRenderHTML(expectedRaw)
- result := parseSortAndRenderHTML(Render(div))
+ expected := sortHtmlAttributes(expectedRaw)
+ result := sortHtmlAttributes(Render(div))
assert.Equal(t,
expected,
@@ -123,6 +125,64 @@ func TestTagSelfClosing(t *testing.T) {
))
}
+func TestCached(t *testing.T) {
+ t.Parallel()
+ count := 0
+ page := Cached(time.Hour, func() *Element {
+ count++
+ return ComplexPage()
+ })
+
+ firstRender := sortHtmlAttributes(Render(page()))
+ secondRender := sortHtmlAttributes(Render(page()))
+
+ assert.Equal(t, firstRender, secondRender)
+ assert.Equal(t, 1, count)
+ assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage())))
+}
+
+func TestCachedExpired(t *testing.T) {
+ t.Parallel()
+ count := 0
+ page := Cached(time.Millisecond*3, func() *Element {
+ count++
+ return ComplexPage()
+ })
+
+ firstRender := sortHtmlAttributes(Render(page()))
+ time.Sleep(time.Millisecond * 5)
+ secondRender := sortHtmlAttributes(Render(page()))
+
+ assert.Equal(t, firstRender, secondRender)
+ assert.Equal(t, 2, count)
+}
+
+func TestCacheMultiple(t *testing.T) {
+ t.Parallel()
+ count := 0
+ cachedItem := Cached(time.Hour, func() *Element {
+ count++
+ return Div(Text("hello"))
+ })
+
+ wg := sync.WaitGroup{}
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ Render(Div(
+ cachedItem(),
+ cachedItem(),
+ cachedItem(),
+ ))
+ }()
+ }
+
+ wg.Wait()
+
+ assert.Equal(t, 1, count)
+}
+
func BenchmarkMailToStatic(b *testing.B) {
b.ReportAllocs()
ctx := RenderContext{
@@ -146,19 +206,32 @@ func BenchmarkMailToDynamic(b *testing.B) {
}
}
-func BenchmarkComplexPage(b *testing.B) {
- b.Skip()
+func BenchmarkCachedComplexPage(b *testing.B) {
b.ReportAllocs()
ctx := RenderContext{
builder: &strings.Builder{},
}
- page := ComplexPage()
for i := 0; i < b.N; i++ {
- page.Render(&ctx)
+ CachedComplexPage().Render(&ctx)
ctx.builder.Reset()
}
}
+func BenchmarkComplexPage(b *testing.B) {
+ b.ReportAllocs()
+ ctx := RenderContext{
+ builder: &strings.Builder{},
+ }
+ for i := 0; i < b.N; i++ {
+ ComplexPage().Render(&ctx)
+ ctx.builder.Reset()
+ }
+}
+
+var CachedComplexPage = Cached(time.Hour, func() *Element {
+ return ComplexPage()
+})
+
func ComplexPage() *Element {
return Html(
Head(
diff --git a/framework/h/renderer.go b/framework/h/renderer.go
index 9f245c9..79877fd 100644
--- a/framework/h/renderer.go
+++ b/framework/h/renderer.go
@@ -7,6 +7,12 @@ import (
"strings"
)
+type CustomElement = string
+
+var (
+ CachedNodeTag CustomElement = "htmgo_cache_node"
+)
+
/*
*
void tags are tags that cannot have children
@@ -45,6 +51,12 @@ func (ctx *RenderContext) AddScript(funcName string, body string) {
func (node *Element) Render(context *RenderContext) {
// some elements may not have a tag, such as a Fragment
+ if node.tag == CachedNodeTag {
+ meta := node.meta.(*CachedNode)
+ meta.Render(context)
+ return
+ }
+
if node.tag != "" {
context.builder.WriteString("<")
context.builder.WriteString(node.tag)
diff --git a/framework/h/tag.go b/framework/h/tag.go
index 9e62bb3..9fde493 100644
--- a/framework/h/tag.go
+++ b/framework/h/tag.go
@@ -12,6 +12,7 @@ type PartialFunc = func(ctx *RequestContext) *Partial
type Element struct {
tag string
attributes map[string]string
+ meta any
children []Ren
}
diff --git a/htmgo-site/md/docs/5_performance/1_caching.md b/htmgo-site/md/docs/5_performance/1_caching.md
new file mode 100644
index 0000000..b793e30
--- /dev/null
+++ b/htmgo-site/md/docs/5_performance/1_caching.md
@@ -0,0 +1,51 @@
+**Caching Components**
+
+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.
+
+To cache a component in htmgo, we offer:
+
+```go
+// No arguments passed to the component
+h.Cached(duration time.Duration, cb GetElementFunc)
+// One argument passed to the component
+h.CachedT(duration time.Duration, cb GetElementFunc)
+// Two arguments passed to the component
+h.CachedT2(duration time.Duration, cb GetElementFunc)
+// Three arguments passed to the component
+h.CachedT3(duration time.Duration, cb GetElementFunc)
+// Four arguments passed to the component
+h.CachedT4(duration time.Duration, cb GetElementFunc)
+```
+
+The `duration` parameter is the time the component should be cached for. The `cb` parameter is a function that returns the component.
+
+When a request is made for a cached component, the component is rendered and stored in memory. Subsequent requests for the same component within the cache duration will return the cached component instead of rendering it again.
+
+**Usage:**
+
+```go
+func ExpensiveComponent(ctx *h.RequestContext) *h.Element {
+ // Some expensive call
+ data := http.Get("https://api.example.com/data")
+ return h.Div(
+ h.Text(data),
+ )
+}
+
+var CachedComponent = h.CachedT(5*time.Minute, func(ctx *h.RequestContext) *h.Element {
+ return ExpensiveComponent(ctx)
+})
+
+func IndexPage(ctx *h.RequestContext) *h.Page {
+ return h.NewPage(
+ CachedComponent(ctx),
+ )
+}
+```
+**Note:** We are using CachedT because the component takes one argument, the RequestContext.
+If the component takes more arguments, use CachedT2, CachedT3, etc.
+
+**Important**:
+1. The cached value is stored globally in memory, so it is shared across all requests. Do not store request-specific data in a cached component. Only cache components that you are OK with all users seeing the same data.
+2. Ensure the declaration of the cached component is **outside the function** that uses it. This is to prevent the component from being redeclared on each request.
\ No newline at end of file
diff --git a/htmgo-site/pages/index.go b/htmgo-site/pages/index.go
index d60283d..91cde87 100644
--- a/htmgo-site/pages/index.go
+++ b/htmgo-site/pages/index.go
@@ -8,7 +8,7 @@ import (
func IndexPage(ctx *h.RequestContext) *h.Page {
return h.NewPage(
base.RootPage(ctx, h.Div(
- h.Class("flex items-center justify-center "),
+ h.Class("flex items-center justify-center"),
h.Div(
h.Class("w-full px-4 flex flex-col prose md:max-w-3xl mt-6 mx-auto"),
h.Div(
diff --git a/htmgo-site/partials/navbar.go b/htmgo-site/partials/navbar.go
index b336e22..ea9a396 100644
--- a/htmgo-site/partials/navbar.go
+++ b/htmgo-site/partials/navbar.go
@@ -1,9 +1,8 @@
package partials
import (
+ "fmt"
"github.com/maddalax/htmgo/framework/h"
- "github.com/maddalax/htmgo/framework/service"
- "htmgo-site/internal/cache"
"htmgo-site/internal/httpjson"
"time"
)
@@ -13,6 +12,15 @@ type NavItem struct {
Url string
}
+var navItems = []NavItem{
+ {Name: "Docs", Url: "/docs"},
+ {Name: "Examples", Url: "/examples"},
+}
+
+var CachedStar = h.CachedT(time.Minute*15, func(t *h.RequestContext) *h.Element {
+ return Star(t)
+})
+
func ToggleNavbar(ctx *h.RequestContext) *h.Partial {
return h.SwapManyPartial(
ctx,
@@ -20,25 +28,19 @@ func ToggleNavbar(ctx *h.RequestContext) *h.Partial {
)
}
-var navItems = []NavItem{
- {Name: "Docs", Url: "/docs"},
- {Name: "Examples", Url: "/examples"},
-}
-
func Star(ctx *h.RequestContext) *h.Element {
type Repo struct {
StarCount int `json:"stargazers_count"`
}
- simpleCache := service.Get[cache.SimpleCache](ctx.ServiceLocator())
- count := cache.GetOrSet(simpleCache, "starCount", 10*time.Minute, func() (int, bool) {
- response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo")
- if err != nil {
- return 0, false
- }
- return response.StarCount, true
- })
+ fmt.Printf("making github star request\n")
+ count := 0
+ response, err := httpjson.Get[Repo]("https://api.github.com/repos/maddalax/htmgo")
+
+ if err == nil && response != nil {
+ count = response.StarCount
+ }
return h.A(
h.Href("https://github.com/maddalax/htmgo"),
@@ -98,7 +100,7 @@ func NavBar(ctx *h.RequestContext, expanded bool) *h.Element {
),
)
}),
- Star(ctx),
+ CachedStar(ctx),
),
),
),
@@ -130,7 +132,7 @@ func MobileNav(ctx *h.RequestContext, expanded bool) *h.Element {
)),
h.Div(
h.Class("flex items-center gap-3"),
- h.Div(h.Class("mt-1"), Star(ctx)),
+ h.Div(h.Class("mt-1"), CachedStar(ctx)),
h.Button(
h.Boost(),
diff --git a/htmgo-site/partials/sidebar.go b/htmgo-site/partials/sidebar.go
index d54e4fd..dd5fd6e 100644
--- a/htmgo-site/partials/sidebar.go
+++ b/htmgo-site/partials/sidebar.go
@@ -69,7 +69,6 @@ func DocSidebar(pages []*dirwalk.Page) *h.Element {
h.Class("pl-4 flex flex-col"),
h.List(entry.Value, func(page *dirwalk.Page, index int) *h.Element {
anchor := CreateAnchor(page.Parts)
- println(anchor)
return h.A(
h.Href("#"+anchor),
h.Text(partsToName(page.Parts)),