go /

Building view-trees: Async Data Fetching [Part 4]

.md | permalink | Published on December 06, 2023

Previously: intro, the basics, and error handling.


There are cases where we'll fetch data for something up front, and pass it down the view tree, meaning the views are basically "pure functions", all done; and there are times when a view will request data for itself, meaning a view constructor could be waiting and blocking.

The below example is of a view constructor kicking off a simulated fetch in a goroutine, based on how its configured the view either fails or succeeds.

ExpensiveViewData is a placeholder for something that could have come from some JSON API.

type ExpensiveViewData struct {
    Title string `json:"title"`
}

The view holds data for success or error and Renderable checks for which channel gets data first (this will block), either getting data, or an error, and then completing the Renderable interface with which ever.

type ExpensiveView struct {
    Data chan ExpensiveViewData
    Err  chan error
}

func (v *ExpensiveView) Renderable() (Renderable, error) {
    select {
    case err := <-v.Err:
        return nil, err
    case data := <-v.Data:
        return View{/*  */}, nil
    }
}

The constructor (function) for our view, because we're simulating work, is parameterized by shouldErr and we kick off a goroutine that waits for 1 second and then sends the message.

func NewExpensiveView(shouldErr bool) *ExpensiveView {
    errCh := make(chan err)
    dataCh := make(chan ExpensiveViewData)

    go func() {
        defer close(errCh)
        defer close(dataCh)

        // do data fetching and either write to one thing or the other
        time.Sleep(1 * time.Second)
        if shouldErr {
            errCh <- fmt.Errorf("fetch failed")
        } else {
            dataCh <- ExpensiveViewData{Title: "hi"}
        }
    }()

    return &ExpensiveView{Data: dataCh, Err:  errCh}
}
initial channel data fetch test (source: 3b46e0cb)
diff --git a/render_with_data_fetch_test.go b/render_with_data_fetch_test.go
new file mode 100644
index 0000000..963242e
--- /dev/null
+++ b/render_with_data_fetch_test.go
@@ -0,0 +1,68 @@
+package veun_test
+
+import (
+	"fmt"
+	"html/template"
+	"testing"
+	"time"
+
+	"github.com/alecthomas/assert/v2"
+
+	. "github.com/stanistan/veun"
+)
+
+type ExpensiveViewData struct {
+	Title string `json:"title"`
+}
+
+var expensiveViewTpl = MustParseTemplate("expensiveView", `{{ .Title }} success`)
+
+type ExpensiveView struct {
+	Data chan ExpensiveViewData
+	Err  chan error
+}
+
+func NewExpensiveView(shouldErr bool) *ExpensiveView {
+	errCh := make(chan error)
+	dataCh := make(chan ExpensiveViewData)
+
+	go func() {
+		defer func() {
+			close(errCh)
+			close(dataCh)
+		}()
+
+		// do data fetching and either write to
+		// one thing or the other
+		time.Sleep(1 * time.Millisecond)
+		if shouldErr {
+			errCh <- fmt.Errorf("fetch failed")
+		} else {
+			dataCh <- ExpensiveViewData{Title: "hi"}
+		}
+	}()
+
+	return &ExpensiveView{Data: dataCh, Err: errCh}
+}
+
+func (v *ExpensiveView) Renderable() (Renderable, error) {
+	select {
+	case err := <-v.Err:
+		return nil, err
+	case data := <-v.Data:
+		return View{Tpl: expensiveViewTpl, Data: data}, nil
+	}
+}
+
+func TestViewWithChannels(t *testing.T) {
+	t.Run("successful", func(t *testing.T) {
+		html, err := Render(NewExpensiveView(false))
+		assert.NoError(t, err)
+		assert.Equal(t, template.HTML(`hi success`), html)
+	})
+
+	t.Run("failed", func(t *testing.T) {
+		_, err := Render(NewExpensiveView(true))
+		assert.Error(t, err)
+	})
+}

This works just fine, but what if we don't want this to be waiting for a second, what if we have 10ms to do the work?

Context and Cancellation

We need something that could do cancellation in case of a timeout. We're missing context.Context.

Where this is mostly going to be used, in the span of an HTTP request, we can grab this from the request itself, but our API has no method of propagating it down through construction, render, and data fetching.

What we want is for our select to look like this:

select {
case <-ctx.Done():
    return nil, ctx.Err()
case err := <-v.Err:
    return nil, err
case data := <-v.Data:
    return View{/*  */}, nil
}

And eventually, our API to look like this:

func (v *ExpensiveView) Renderable(ctx context.Context) (Renderable, error) {
    // ...
}

So we have some changes to make.

BeforeAfter
Renderable()Renderable(Context)
Template()Template(Context)
TemplateData()TemplateData(Context)
Render(AsRenderable)Render(Context, AsRenderable)
Adding context.Context to all interface methods (source: 81d7c2de)
diff --git a/error_renderable.go b/error_renderable.go
index 63159e8..0dcab6c 100644
--- a/error_renderable.go
+++ b/error_renderable.go
@@ -1,6 +1,9 @@
 package veun
 
-import "html/template"
+import (
+	"context"
+	"html/template"
+)
 
 type ErrorRenderable interface {
 	// ErrorRenderable can return bubble the error
@@ -11,10 +14,10 @@ type ErrorRenderable interface {
 	// which will ignore the error entirely.
 	//
 	// Otherwise we will attempt to render next one.
-	ErrorRenderable(err error) (AsRenderable, error)
+	ErrorRenderable(ctx context.Context, err error) (AsRenderable, error)
 }
 
-func handleRenderError(err error, with any) (template.HTML, error) {
+func handleRenderError(ctx context.Context, err error, with any) (template.HTML, error) {
 	var empty template.HTML
 
 	if with == nil {
@@ -26,7 +29,7 @@ func handleRenderError(err error, with any) (template.HTML, error) {
 		return empty, err
 	}
 
-	r, err := errRenderable.ErrorRenderable(err)
+	r, err := errRenderable.ErrorRenderable(ctx, err)
 	if err != nil {
 		return empty, err
 	}
@@ -35,5 +38,5 @@ func handleRenderError(err error, with any) (template.HTML, error) {
 		return empty, nil
 	}
 
-	return Render(r)
+	return Render(ctx, r)
 }
diff --git a/render_container_as_view_test.go b/render_container_as_view_test.go
index 9af1ad0..b83ff1a 100644
--- a/render_container_as_view_test.go
+++ b/render_container_as_view_test.go
@@ -1,6 +1,7 @@
 package veun_test
 
 import (
+	"context"
 	"html/template"
 	"testing"
 
@@ -14,7 +15,7 @@ type ContainerView2 struct {
 	Body    AsRenderable
 }
 
-func (v ContainerView2) Renderable() (Renderable, error) {
+func (v ContainerView2) Renderable(ctx context.Context) (Renderable, error) {
 	return View{
 		Tpl:   containerViewTpl,
 		Slots: Slots{"heading": v.Heading, "body": v.Body},
@@ -22,7 +23,7 @@ func (v ContainerView2) Renderable() (Renderable, error) {
 }
 
 func TestRenderContainerAsView(t *testing.T) {
-	html, err := Render(ContainerView2{
+	html, err := Render(context.Background(), ContainerView2{
 		Heading: ChildView1{},
 		Body:    ChildView2{},
 	})
diff --git a/render_container_error_test.go b/render_container_error_test.go
index 8052a8d..abedcca 100644
--- a/render_container_error_test.go
+++ b/render_container_error_test.go
@@ -1,6 +1,7 @@
 package veun_test
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"html/template"
@@ -15,7 +16,7 @@ type FailingView struct {
 	Err error
 }
 
-func (v FailingView) Renderable() (Renderable, error) {
+func (v FailingView) Renderable(_ context.Context) (Renderable, error) {
 	return nil, fmt.Errorf("FailingView.Renderable(): %w", v.Err)
 }
 
@@ -24,11 +25,11 @@ type FallibleView struct {
 	Child       AsRenderable
 }
 
-func (v FallibleView) Renderable() (Renderable, error) {
-	return v.Child.Renderable()
+func (v FallibleView) Renderable(ctx context.Context) (Renderable, error) {
+	return v.Child.Renderable(ctx)
 }
 
-func (v FallibleView) ErrorRenderable(err error) (AsRenderable, error) {
+func (v FallibleView) ErrorRenderable(ctx context.Context, err error) (AsRenderable, error) {
 	if v.CapturesErr == nil {
 		return nil, err
 	}
@@ -41,7 +42,7 @@ func (v FallibleView) ErrorRenderable(err error) (AsRenderable, error) {
 }
 
 func TestRenderContainerWithFailingView(t *testing.T) {
-	_, err := Render(ContainerView2{
+	_, err := Render(context.Background(), ContainerView2{
 		Heading: ChildView1{},
 		Body: FailingView{
 			Err: fmt.Errorf("construction: %w", errSomethingFailed),
@@ -52,7 +53,7 @@ func TestRenderContainerWithFailingView(t *testing.T) {
 
 func TestRenderContainerWithCapturedError(t *testing.T) {
 	t.Run("errors_bubble_out", func(t *testing.T) {
-		_, err := Render(ContainerView2{
+		_, err := Render(context.Background(), ContainerView2{
 			Heading: ChildView1{},
 			Body: FallibleView{
 				Child: FailingView{Err: errSomethingFailed},
@@ -62,7 +63,7 @@ func TestRenderContainerWithCapturedError(t *testing.T) {
 	})
 
 	t.Run("errors_can_push_replacement_views", func(t *testing.T) {
-		html, err := Render(ContainerView2{
+		html, err := Render(context.Background(), ContainerView2{
 			Heading: ChildView1{},
 			Body: FallibleView{
 				Child:       FailingView{Err: errSomethingFailed},
@@ -77,7 +78,7 @@ func TestRenderContainerWithCapturedError(t *testing.T) {
 	})
 
 	t.Run("errors_can_return_nil_views", func(t *testing.T) {
-		html, err := Render(ContainerView2{
+		html, err := Render(context.Background(), ContainerView2{
 			Heading: ChildView1{},
 			Body: FallibleView{
 				Child:       FailingView{Err: errors.New("hi")},
diff --git a/render_container_test.go b/render_container_test.go
index 162f0d2..aef4e68 100644
--- a/render_container_test.go
+++ b/render_container_test.go
@@ -1,6 +1,7 @@
 package veun_test
 
 import (
+	"context"
 	"html/template"
 	"testing"
 
@@ -19,30 +20,30 @@ var containerViewTpl = MustParseTemplate("containerView", `<div>
 	<div class="body">{{ slot "body" }}</div>
 </div>`)
 
-func tplWithRealSlotFunc(tpl *template.Template, slots map[string]AsRenderable) *template.Template {
+func tplWithRealSlotFunc(ctx context.Context, tpl *template.Template, slots map[string]AsRenderable) *template.Template {
 	return tpl.Funcs(template.FuncMap{
 		"slot": func(name string) (template.HTML, error) {
 			slot, ok := slots[name]
 			if ok {
-				return Render(slot)
+				return Render(ctx, slot)
 			}
 			return template.HTML(""), nil
 		},
 	})
 }
 
-func (v ContainerView) Template() (*template.Template, error) {
-	return tplWithRealSlotFunc(containerViewTpl, map[string]AsRenderable{
+func (v ContainerView) Template(ctx context.Context) (*template.Template, error) {
+	return tplWithRealSlotFunc(ctx, containerViewTpl, map[string]AsRenderable{
 		"heading": v.Heading,
 		"body":    v.Body,
 	}), nil
 }
 
-func (v ContainerView) TemplateData() (any, error) {
+func (v ContainerView) TemplateData(_ context.Context) (any, error) {
 	return nil, nil
 }
 
-func (v ContainerView) Renderable() (Renderable, error) {
+func (v ContainerView) Renderable(_ context.Context) (Renderable, error) {
 	return v, nil
 }
 
@@ -52,18 +53,18 @@ var childViewTemplate = template.Must(
 
 type ChildView1 struct{}
 
-func (v ChildView1) Renderable() (Renderable, error) {
+func (v ChildView1) Renderable(_ context.Context) (Renderable, error) {
 	return View{Tpl: childViewTemplate, Data: "HEADING"}, nil
 }
 
 type ChildView2 struct{}
 
-func (v ChildView2) Renderable() (Renderable, error) {
+func (v ChildView2) Renderable(_ context.Context) (Renderable, error) {
 	return View{Tpl: childViewTemplate, Data: "BODY"}, nil
 }
 
 func TestRenderContainer(t *testing.T) {
-	html, err := Render(&ContainerView{
+	html, err := Render(context.Background(), &ContainerView{
 		Heading: ChildView1{},
 		Body:    ChildView2{},
 	})
diff --git a/render_person_test.go b/render_person_test.go
index e2a99de..5de4182 100644
--- a/render_person_test.go
+++ b/render_person_test.go
@@ -1,6 +1,7 @@
 package veun_test
 
 import (
+	"context"
 	"html/template"
 	"testing"
 
@@ -24,12 +25,12 @@ var personViewTpl = template.Must(
 	template.New("PersonView").Parse(`<div>Hi, {{ .Name }}.</div>`),
 )
 
-func (v *personView) Renderable() (Renderable, error) {
+func (v *personView) Renderable(_ context.Context) (Renderable, error) {
 	return View{Tpl: personViewTpl, Data: v.Person}, nil
 }
 
 func TestRenderPerson(t *testing.T) {
-	html, err := Render(PersonView(Person{Name: "Stan"}))
+	html, err := Render(context.Background(), PersonView(Person{Name: "Stan"}))
 	assert.NoError(t, err)
 	assert.Equal(t, html, template.HTML(`<div>Hi, Stan.</div>`))
 }
diff --git a/render_with_data_fetch_test.go b/render_with_data_fetch_test.go
index 963242e..e6166ed 100644
--- a/render_with_data_fetch_test.go
+++ b/render_with_data_fetch_test.go
@@ -1,6 +1,7 @@
 package veun_test
 
 import (
+	"context"
 	"fmt"
 	"html/template"
 	"testing"
@@ -45,7 +46,7 @@ func NewExpensiveView(shouldErr bool) *ExpensiveView {
 	return &ExpensiveView{Data: dataCh, Err: errCh}
 }
 
-func (v *ExpensiveView) Renderable() (Renderable, error) {
+func (v *ExpensiveView) Renderable(_ context.Context) (Renderable, error) {
 	select {
 	case err := <-v.Err:
 		return nil, err
@@ -56,13 +57,13 @@ func (v *ExpensiveView) Renderable() (Renderable, error) {
 
 func TestViewWithChannels(t *testing.T) {
 	t.Run("successful", func(t *testing.T) {
-		html, err := Render(NewExpensiveView(false))
+		html, err := Render(context.Background(), NewExpensiveView(false))
 		assert.NoError(t, err)
 		assert.Equal(t, template.HTML(`hi success`), html)
 	})
 
 	t.Run("failed", func(t *testing.T) {
-		_, err := Render(NewExpensiveView(true))
+		_, err := Render(context.Background(), NewExpensiveView(true))
 		assert.Error(t, err)
 	})
 }
diff --git a/renderer.go b/renderer.go
index 27d3abc..33af885 100644
--- a/renderer.go
+++ b/renderer.go
@@ -2,37 +2,38 @@ package veun
 
 import (
 	"bytes"
+	"context"
 	"fmt"
 	"html/template"
 )
 
 type Renderable interface {
-	Template() (*template.Template, error)
-	TemplateData() (any, error)
+	Template(ctx context.Context) (*template.Template, error)
+	TemplateData(ctx context.Context) (any, error)
 }
 
 type AsRenderable interface {
-	Renderable() (Renderable, error)
+	Renderable(ctx context.Context) (Renderable, error)
 }
 
-func Render(r AsRenderable) (template.HTML, error) {
-	renderable, err := r.Renderable()
+func Render(ctx context.Context, r AsRenderable) (template.HTML, error) {
+	renderable, err := r.Renderable(ctx)
 	if err != nil {
-		return handleRenderError(err, r)
+		return handleRenderError(ctx, err, r)
 	}
 
-	out, err := render(renderable)
+	out, err := render(ctx, renderable)
 	if err != nil {
-		return handleRenderError(err, r)
+		return handleRenderError(ctx, err, r)
 	}
 
 	return out, nil
 }
 
-func render(r Renderable) (template.HTML, error) {
+func render(ctx context.Context, r Renderable) (template.HTML, error) {
 	var empty template.HTML
 
-	tpl, err := r.Template()
+	tpl, err := r.Template(ctx)
 	if err != nil {
 		return empty, err
 	}
@@ -41,7 +42,7 @@ func render(r Renderable) (template.HTML, error) {
 		return empty, fmt.Errorf("missing template")
 	}
 
-	data, err := r.TemplateData()
+	data, err := r.TemplateData(ctx)
 	if err != nil {
 		return empty, err
 	}
diff --git a/slots.go b/slots.go
index ef55359..61f7fb4 100644
--- a/slots.go
+++ b/slots.go
@@ -1,19 +1,24 @@
 package veun
 
-import "html/template"
+import (
+	"context"
+	"html/template"
+)
 
 type Slots map[string]AsRenderable
 
-func (s Slots) renderSlot(name string) (template.HTML, error) {
-	slot, ok := s[name]
-	if ok {
-		return Render(slot)
-	}
+func (s Slots) renderSlot(ctx context.Context) func(string) (template.HTML, error) {
+	return func(name string) (template.HTML, error) {
+		slot, ok := s[name]
+		if ok {
+			return Render(ctx, slot)
+		}
 
-	var empty template.HTML
-	return empty, nil
+		var empty template.HTML
+		return empty, nil
+	}
 }
 
-func (s Slots) addToTemplate(t *template.Template) *template.Template {
-	return t.Funcs(template.FuncMap{"slot": s.renderSlot})
+func (s Slots) addToTemplate(ctx context.Context, t *template.Template) *template.Template {
+	return t.Funcs(template.FuncMap{"slot": s.renderSlot(ctx)})
 }
diff --git a/view.go b/view.go
index 2bfc217..97f02f5 100644
--- a/view.go
+++ b/view.go
@@ -1,6 +1,9 @@
 package veun
 
-import "html/template"
+import (
+	"context"
+	"html/template"
+)
 
 type View struct {
 	Tpl   *template.Template
@@ -8,15 +11,15 @@ type View struct {
 	Data  any
 }
 
-func (v View) Template() (*template.Template, error) {
-	return v.Slots.addToTemplate(v.Tpl), nil
+func (v View) Template(ctx context.Context) (*template.Template, error) {
+	return v.Slots.addToTemplate(ctx, v.Tpl), nil
 }
 
-func (v View) TemplateData() (any, error) {
+func (v View) TemplateData(_ context.Context) (any, error) {
 	return v.Data, nil
 }
 
-func (v View) Renderable() (Renderable, error) {
+func (v View) Renderable(_ context.Context) (Renderable, error) {
 	return v, nil
 }
 

testing for cancelled context (source: a6a523ee)
diff --git a/render_with_data_fetch_test.go b/render_with_data_fetch_test.go
index e6166ed..4d93650 100644
--- a/render_with_data_fetch_test.go
+++ b/render_with_data_fetch_test.go
@@ -23,7 +23,7 @@ type ExpensiveView struct {
 	Err  chan error
 }
 
-func NewExpensiveView(shouldErr bool) *ExpensiveView {
+func NewExpensiveView(shouldErr bool, sleepFor time.Duration) *ExpensiveView {
 	errCh := make(chan error)
 	dataCh := make(chan ExpensiveViewData)
 
@@ -35,7 +35,7 @@ func NewExpensiveView(shouldErr bool) *ExpensiveView {
 
 		// do data fetching and either write to
 		// one thing or the other
-		time.Sleep(1 * time.Millisecond)
+		time.Sleep(sleepFor)
 		if shouldErr {
 			errCh <- fmt.Errorf("fetch failed")
 		} else {
@@ -46,8 +46,10 @@ func NewExpensiveView(shouldErr bool) *ExpensiveView {
 	return &ExpensiveView{Data: dataCh, Err: errCh}
 }
 
-func (v *ExpensiveView) Renderable(_ context.Context) (Renderable, error) {
+func (v *ExpensiveView) Renderable(ctx context.Context) (Renderable, error) {
 	select {
+	case <-ctx.Done():
+		return nil, ctx.Err()
 	case err := <-v.Err:
 		return nil, err
 	case data := <-v.Data:
@@ -57,13 +59,25 @@ func (v *ExpensiveView) Renderable(_ context.Context) (Renderable, error) {
 
 func TestViewWithChannels(t *testing.T) {
 	t.Run("successful", func(t *testing.T) {
-		html, err := Render(context.Background(), NewExpensiveView(false))
+		html, err := Render(context.Background(), NewExpensiveView(false, 1*time.Millisecond))
 		assert.NoError(t, err)
 		assert.Equal(t, template.HTML(`hi success`), html)
 	})
 
 	t.Run("failed", func(t *testing.T) {
-		_, err := Render(context.Background(), NewExpensiveView(true))
+		_, err := Render(context.Background(), NewExpensiveView(true, 1*time.Millisecond))
 		assert.Error(t, err)
 	})
+
+	t.Run("context timed out", func(t *testing.T) {
+		ctx, _ := context.WithTimeout(context.Background(), 1*time.Millisecond)
+		_, err := Render(ctx, NewExpensiveView(false, 2*time.Millisecond))
+		assert.Error(t, err)
+	})
+
+	t.Run("context timeout not reached", func(t *testing.T) {
+		ctx, _ := context.WithTimeout(context.Background(), 5*time.Millisecond)
+		_, err := Render(ctx, NewExpensiveView(false, 2*time.Millisecond))
+		assert.NoError(t, err)
+	})
 }

Fallible and WithTimeout

Because we can do call delegation in our views and render, we can force a situation where a subtree always stops rendering by creating a view which explicitly cancels a subcontext and passes it to the its subview.

type ViewWithTimeout struct {
    Delegate AsRenderable
    Timeout time.Duration
}

func (v ViewWithTimeout) Renderable(ctx context.Context) (Renderable, error) {
    ctx, _ = context.WithTimeout(ctx, v.Timeout)
    return v.Delegate.Renderable(ctx)
}

And using the FallibleView from part-2 we can make sure that something fetches and renders within a given time frame.

view := FallibleView{
    Contents: ViewWithTimeout{
        Delegate: expensiveView(),
        Timeout: 100 * time.Millisecond,
    },
    ErrorRenderable: func(_ context.Context, err error) (AsRenderable, error) {
        return View{/* */}, nil
    },
}
testing context and timeouts and fallbacks (source: 6f95e9bb)
diff --git a/render_with_data_fetch_test.go b/render_with_data_fetch_test.go
index 4d93650..593784c 100644
--- a/render_with_data_fetch_test.go
+++ b/render_with_data_fetch_test.go
@@ -57,6 +57,16 @@ func (v *ExpensiveView) Renderable(ctx context.Context) (Renderable, error) {
 	}
 }
 
+type ViewWithTimeout struct {
+	Delegate AsRenderable
+	Timeout  time.Duration
+}
+
+func (v ViewWithTimeout) Renderable(ctx context.Context) (Renderable, error) {
+	ctx, _ = context.WithTimeout(ctx, v.Timeout)
+	return v.Delegate.Renderable(ctx)
+}
+
 func TestViewWithChannels(t *testing.T) {
 	t.Run("successful", func(t *testing.T) {
 		html, err := Render(context.Background(), NewExpensiveView(false, 1*time.Millisecond))
@@ -80,4 +90,16 @@ func TestViewWithChannels(t *testing.T) {
 		_, err := Render(ctx, NewExpensiveView(false, 2*time.Millisecond))
 		assert.NoError(t, err)
 	})
+
+	t.Run("with timeout and fallible", func(t *testing.T) {
+		html, err := Render(context.Background(), FallibleView{
+			Child: ViewWithTimeout{
+				Delegate: NewExpensiveView(false, 10*time.Millisecond),
+				Timeout:  2 * time.Millisecond,
+			},
+			CapturesErr: context.DeadlineExceeded,
+		})
+		assert.NoError(t, err)
+		assert.Equal(t, template.HTML(`HEADING`), html)
+	})
 }

Cleaning up, and examples of Context composition

We can make our own very similar WithTimeout function.

func WithTimeout(r AsRenderable, timeout time.Duration) AsRenderable {
    // ...
}

This is just a function signature, but seeing this immediately makes me think of ways that something can be extracted into a different kind of pattern.

HTTP middleware has the signature: func(http.Handler) http.Handler.

We can update our function to look like this:

func WithTimeout(timeout time.Duration) func(AsRenderable) AsRenderable {
    return func(r AsRenderable) AsRenderable {
        // ...
    }
}

Is this actually useful? How would this be used in practice?

Probably not by doing: WithTimeout(timeout)(view), but if we had some way of applying these, like: Compose(view, WithTimeout(timeout)), this might be ok.

Let's just save this idea for later...

A Renderable function

Something that is interesting here though is the part we gloss over, (// ...). Sometimes we don't need a full struct, sometimes we only need a closure, and the resulting code can be clearer and simpler to reason about.

type RenderableFunc func(context.Context) (Renderable, error)

func (f RenderableFunc) Renderable(ctx context.Context) (Renderable, error) {
    return f(ctx)
}
moving renderable to renderable.go, adding RenderableFunc (source: 8c05c665)
diff --git a/renderable.go b/renderable.go
new file mode 100644
index 0000000..57f3791
--- /dev/null
+++ b/renderable.go
@@ -0,0 +1,21 @@
+package veun
+
+import (
+	"context"
+	"html/template"
+)
+
+type Renderable interface {
+	Template(ctx context.Context) (*template.Template, error)
+	TemplateData(ctx context.Context) (any, error)
+}
+
+type AsRenderable interface {
+	Renderable(ctx context.Context) (Renderable, error)
+}
+
+type RenderableFunc func(context.Context) (Renderable, error)
+
+func (f RenderableFunc) Renderable(ctx context.Context) (Renderable, error) {
+	return f(ctx)
+}
diff --git a/renderer.go b/renderer.go
index 33af885..ebbddef 100644
--- a/renderer.go
+++ b/renderer.go
@@ -7,15 +7,6 @@ import (
 	"html/template"
 )
 
-type Renderable interface {
-	Template(ctx context.Context) (*template.Template, error)
-	TemplateData(ctx context.Context) (any, error)
-}
-
-type AsRenderable interface {
-	Renderable(ctx context.Context) (Renderable, error)
-}
-
 func Render(ctx context.Context, r AsRenderable) (template.HTML, error) {
 	renderable, err := r.Renderable(ctx)
 	if err != nil {

And now to use it:

func WithTimeout(timeout time.Duration) func(AsRenderable) AsRenderable {
    return func(r AsRenderable) AsRenderable {
        return RenderableFunc(func(ctx context.Context) (Renderable, error) {
            ctx, _ = context.WithTimeout(timeout)
            return r.Renderable(ctx)
        })
    }
}

N.B. Because go contexts are copies, cancelling subtree renders MUST BE done through delegating.

func WithErrorHandler(eh ErrorRenderable) func(AsRenderable) AsRenderable {
    return func(r AsRenderable) AsRenderable {
        return FallibleView{Contents: r, ErrorRenderable: eh}
    }
}

Can we put it together?

func Compose(r AsRenderable, fs ...func(AsRenderable) AsRenderable) AsRenderable {
    for _, f := range fs {
        r = f(r)
    }
    return r
}

r := Compose(r, WithTimeout(timeout), WithErrorHandler(eh))
html, err := Render(ctx, r)

Except for writing AsRenderable over and over and over again, that's not so bad, and the usage is nice.


Next: