From e5633bb8108fee6cd289da5cb6b7f5138c0e1922 Mon Sep 17 00:00:00 2001 From: Adrian Hesketh Date: Thu, 23 May 2024 18:16:43 +0100 Subject: [PATCH] feat: add templ.Once function (#750) Co-authored-by: Joe Davidson --- .../03-syntax-and-usage/18-render-once.md | 102 ++++++++++++++++ generator/test-once/expected.html | 7 ++ generator/test-once/render_test.go | 23 ++++ generator/test-once/template.templ | 19 +++ generator/test-once/template_templ.go | 109 ++++++++++++++++++ once.go | 43 +++++++ once_test.go | 106 +++++++++++++++++ runtime.go | 22 +++- 8 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 docs/docs/03-syntax-and-usage/18-render-once.md create mode 100644 generator/test-once/expected.html create mode 100644 generator/test-once/render_test.go create mode 100644 generator/test-once/template.templ create mode 100644 generator/test-once/template_templ.go create mode 100644 once.go create mode 100644 once_test.go diff --git a/docs/docs/03-syntax-and-usage/18-render-once.md b/docs/docs/03-syntax-and-usage/18-render-once.md new file mode 100644 index 000000000..d2c42cda5 --- /dev/null +++ b/docs/docs/03-syntax-and-usage/18-render-once.md @@ -0,0 +1,102 @@ +# Render once + +If you need to render something to the page once per page, you can create a `*OnceHandler` with `templ.NewOnceHandler()` and use its `Once()` method. + +The `*OnceHandler.Once()` method ensures that the content is only rendered once per distinct context passed to the component's `Render` method, even if the component is rendered multiple times. + +## Example + +The `hello` JavaScript function is only rendered once, even though the `hello` component is rendered twice. + +:::warning +Dont write `@templ.NewOnceHandle().Once()` - this creates a new `*OnceHandler` each time the `Once` method is called, and will result in the content being rendered multiple times. +::: + +```templ title="component.templ" +package once + +var helloHandle = templ.NewOnceHandle() + +templ hello(label, name string) { + @helloHandle.Once() { + + } + +} + +templ page() { + @hello("Hello User", "user") + @hello("Hello World", "world") +} +``` + +```html title="Output" + + + +``` + +:::tip +Note the use of the `data-name` attribute to pass the `name` value from server-side Go code to the client-side JavaScript code. + +The value of `name` is collected by the `onclick` handler, and passed to the `hello` function. + +To pass complex data structures, consider using a `data-` attribute to pass a JSON string using the `templ.JSONString` function, or use the `templ.JSONScript` function to create a templ component that creates a ` + } +} +``` + +You can then use the `JQuery` component in other packages, and the jQuery library will only be included once in the rendered HTML. + +```templ title="main.templ" +package main + +import "deps" + +templ page() { + + + @deps.JQuery() + + +

Hello, World!

+ @button() + + +} + +templ button() { + @deps.JQuery() + +} +``` diff --git a/generator/test-once/expected.html b/generator/test-once/expected.html new file mode 100644 index 000000000..2103bd24a --- /dev/null +++ b/generator/test-once/expected.html @@ -0,0 +1,7 @@ + + + diff --git a/generator/test-once/render_test.go b/generator/test-once/render_test.go new file mode 100644 index 000000000..3d1f2376b --- /dev/null +++ b/generator/test-once/render_test.go @@ -0,0 +1,23 @@ +package once + +import ( + _ "embed" + "testing" + + "github.com/a-h/templ/generator/htmldiff" +) + +//go:embed expected.html +var expected string + +func Test(t *testing.T) { + component := render() + + diff, err := htmldiff.Diff(component, expected) + if err != nil { + t.Fatal(err) + } + if diff != "" { + t.Error(diff) + } +} diff --git a/generator/test-once/template.templ b/generator/test-once/template.templ new file mode 100644 index 000000000..7fd03e882 --- /dev/null +++ b/generator/test-once/template.templ @@ -0,0 +1,19 @@ +package once + +var helloHandle = templ.NewOnceHandle() + +templ hello(label, name string) { + @helloHandle.Once() { + + } + +} + +templ render() { + @hello("Hello User", "user") + @hello("Hello World", "world") +} diff --git a/generator/test-once/template_templ.go b/generator/test-once/template_templ.go new file mode 100644 index 000000000..1d76dd490 --- /dev/null +++ b/generator/test-once/template_templ.go @@ -0,0 +1,109 @@ +// Code generated by templ - DO NOT EDIT. + +package once + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import "context" +import "io" +import "bytes" + +var helloHandle = templ.NewOnceHandle() + +func hello(label, name string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer) + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = helloHandle.Once().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} + +func render() templ.Component { + return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) + if !templ_7745c5c3_IsBuffer { + templ_7745c5c3_Buffer = templ.GetBuffer() + defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = hello("Hello User", "user").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = hello("Hello World", "world").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !templ_7745c5c3_IsBuffer { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W) + } + return templ_7745c5c3_Err + }) +} diff --git a/once.go b/once.go new file mode 100644 index 000000000..a69d0b20e --- /dev/null +++ b/once.go @@ -0,0 +1,43 @@ +package templ + +import ( + "context" + "io" + "sync/atomic" +) + +// onceHandleIndex is used to identify unique once handles in a program run. +var onceHandleIndex int64 + +// NewOnceHandle creates a OnceHandle used to ensure that the children of its +// `Once` method are only rendered once per context. +func NewOnceHandle() *OnceHandle { + return &OnceHandle{ + id: atomic.AddInt64(&onceHandleIndex, 1), + } +} + +// OnceHandle is used to ensure that the children of its `Once` method are are only +// rendered once per context. +type OnceHandle struct { + // id is used to identify which instance of the OnceHandle is being used. + // The OnceHandle can't be an empty struct, because: + // + // | Two distinct zero-size variables may + // | have the same address in memory + // + // https://go.dev/ref/spec#Size_and_alignment_guarantees + id int64 +} + +// Once returns a component that renders its children once per context. +func (o *OnceHandle) Once() Component { + return ComponentFunc(func(ctx context.Context, w io.Writer) (err error) { + _, v := getContext(ctx) + if v.getHasBeenRendered(o) { + return nil + } + v.setHasBeenRendered(o) + return GetChildren(ctx).Render(ctx, w) + }) +} diff --git a/once_test.go b/once_test.go new file mode 100644 index 000000000..c2681be30 --- /dev/null +++ b/once_test.go @@ -0,0 +1,106 @@ +package templ_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/a-h/templ" + "github.com/google/go-cmp/cmp" +) + +type onceHandleTest struct { + ctx context.Context + expected string +} + +func TestOnceHandle(t *testing.T) { + withHello := templ.WithChildren(context.Background(), templ.Raw("hello")) + tests := []struct { + name string + tests []onceHandleTest + }{ + { + name: "renders nothing without children", + tests: []onceHandleTest{ + { + ctx: context.Background(), + expected: "", + }, + }, + }, + { + name: "children are rendered", + tests: []onceHandleTest{ + { + ctx: templ.WithChildren(context.Background(), templ.Raw("hello")), + expected: "hello", + }, + }, + }, + { + name: "children are rendered once per context", + tests: []onceHandleTest{ + { + ctx: withHello, + expected: "hello", + }, + { + ctx: withHello, + expected: "", + }, + }, + }, + { + name: "different contexts have different once state", + tests: []onceHandleTest{ + { + ctx: templ.WithChildren(context.Background(), templ.Raw("hello")), + expected: "hello", + }, + { + ctx: templ.WithChildren(context.Background(), templ.Raw("hello2")), + expected: "hello2", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := templ.NewOnceHandle().Once() + for i, test := range tt.tests { + t.Run(fmt.Sprintf("render %d/%d", i+1, len(tt.tests)), func(t *testing.T) { + html, err := templ.ToGoHTML(test.ctx, c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(test.expected, string(html)); diff != "" { + t.Errorf("unexpected diff:\n%v", diff) + } + }) + } + }) + } + t.Run("each new handle manages different state", func(t *testing.T) { + ctx := templ.WithChildren(context.Background(), templ.Raw("hello")) + h1 := templ.NewOnceHandle() + c1 := h1.Once() + h2 := templ.NewOnceHandle() + c2 := h2.Once() + c3 := h2.Once() + var w strings.Builder + if err := c1.Render(ctx, &w); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := c2.Render(ctx, &w); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := c3.Render(ctx, &w); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff("hellohello", w.String()); diff != "" { + t.Errorf("unexpected diff:\n%v", diff) + } + }) +} diff --git a/runtime.go b/runtime.go index ef9df246a..1a18e9041 100644 --- a/runtime.go +++ b/runtime.go @@ -593,9 +593,25 @@ type contextKeyType int const contextKey = contextKeyType(0) type contextValue struct { - ss map[string]struct{} - children *Component - nonce string + ss map[string]struct{} + onceHandles map[*OnceHandle]struct{} + children *Component + nonce string +} + +func (v *contextValue) setHasBeenRendered(h *OnceHandle) { + if v.onceHandles == nil { + v.onceHandles = map[*OnceHandle]struct{}{} + } + v.onceHandles[h] = struct{}{} +} + +func (v *contextValue) getHasBeenRendered(h *OnceHandle) (ok bool) { + if v.onceHandles == nil { + v.onceHandles = map[*OnceHandle]struct{}{} + } + _, ok = v.onceHandles[h] + return } func (v *contextValue) addScript(s string) {