Core concepts
Data handlers
How a fragment fetches its data — the typed handler contract, dependency tags, 404s, concurrency, the render context, sharing data, and timeouts.
A data handler is the function a fragment calls to get what its template renders. It receives the request's context and the render context, and returns the data, the dependency tags that data came from, and an error. Everything about a page that talks to the outside world — a database, a CMS, an API — happens in data handlers, and nowhere else.
func loadPost(ctx context.Context, rc *collage.RenderContext) (Post, []string, error) {
post, err := store.Post(ctx, rc.Param("slug"))
if err != nil {
return Post{}, nil, err
}
return post, []string{"post:" + post.Slug}, nil
}
content := collage.NewFragment("post", "pages/post.html").
WithDataHandler(collage.DataHandler(loadPost)).
Required().
Build()
The contract
Write a handler against your own type, and adapt it with collage.DataHandler:
func(ctx context.Context, rc *collage.RenderContext) (T, []string, error)
collage.DataHandler is generic over T, so one adapter serves every view type
and the value your handler returns is exactly what the template receives as ..
Nothing in your code needs an untyped value or a type assertion. (It is a function
rather than a method on the builder because Go methods cannot take type
parameters.)
The three results each have a job.
- The data — whatever the template renders. A struct written for the template, a "view", is usually clearer than handing a template a database row. Each fragment gets its own; a child does not see its parent's.
- The tags — the pieces of content this data was built from, such as
"post:hello-world"or"author:ada". They are collected from every fragment on the page, together with the page's ownWithDependencytags, and stored with the cached page, so invalidating a tag drops every page that showed it. Returnnilwhen the data depends on nothing that changes. See Caching. - The error — a non-nil error fails the fragment, and the fragment's failure policy decides what that means for the page.
Tags are kept even when the handler returns an error. A handler that worked out what it depends on and then failed has still said what would invalidate the page.
On an error, collage.DataHandler drops the data rather than passing it on. That
matters for pointer types: a nil *Post returned alongside an error would
otherwise reach the template as a non-nil value holding a nil pointer.
Not found is not an error
A missing record and a broken database are different failures, and a reader should
get a different answer for each: a 404 for the first, a 500 for the second. Say
which by wrapping collage.ErrNotFound:
func loadPost(ctx context.Context, rc *collage.RenderContext) (Post, []string, error) {
slug := rc.Param("slug")
post, err := store.Post(ctx, slug)
if errors.Is(err, sql.ErrNoRows) {
return Post{}, nil, fmt.Errorf("blog: no post %q: %w", slug, collage.ErrNotFound)
}
if err != nil {
return Post{}, nil, fmt.Errorf("blog: load post %q: %w", slug, err)
}
return post, []string{"post:" + slug}, nil
}
On a Required() fragment, an error wrapping collage.ErrNotFound renders the
page's not-found page with a 404; any other error renders its error page with a
500. See Pages and layouts.
Two conditions, both easy to miss:
- Wrap with
%wall the way up. The framework checks witherrors.Is, so a%vanywhere between your store and the handler's return turns the 404 into a 500. - Only a required fragment turns it into a 404. On an optional fragment it is an ordinary failure — the fragment renders nothing or its fallback — because the page is still worth showing without it.
When handlers run
A page's data handlers do not run one at a time. Before a fragment renders its template, the data handlers of every fragment in its slots start at once. A page made of a post, a sidebar and a comment list waits for the slowest of the three, not their sum.
The order that is guaranteed:
- A parent's handler finishes before its children's start. A child can read what its parent put in shared data, and a path parameter its parent resolved.
- Siblings run at the same time, each on its own goroutine, in no particular order.
- Templates still render one at a time, in tree order. Output is identical from
one request to the next, and anything that depends on order — which title wins in
the
<head>, say — is decided by the tree, not by which handler finished first.
That has three consequences for the code you write.
- Handlers must be safe to run concurrently with their siblings. Anything they share — a map, a counter, a client that is not goroutine-safe — needs the same care it would anywhere else in Go.
- Shared data goes through
rc.Getandrc.Set, never theSharedDatamap directly — see below. - A fragment in a slot the template does not render still starts. Its context is cancelled as soon as the template has finished, and its failure is discarded. A handler that is expensive and usually skipped belongs behind something other than a conditional in a template.
The render context
rc *collage.RenderContext is everything a handler knows about the request being
rendered.
| Member | What it gives you |
|---|---|
rc.Request |
The *http.Request being answered. In a static export, a synthetic GET for the page's path |
rc.Locale |
The locale the URL resolved to |
rc.Param(name), rc.PathParams |
What the route's {name} placeholders captured |
rc.Page |
The page being rendered — read only |
rc.Get(key), rc.Set(key, value) |
Values shared between the fragments of one render |
rc.Context() |
The context the render context carries |
rc.HoistTitle, rc.HoistMeta, rc.HoistProperty, rc.HoistLink, rc.HoistAlternate, rc.HoistStylesheet, rc.Hoist |
Declarations for the page's <head> — see Head and SEO |
rc.Asset(path) |
A mounted file's content-addressed URL — see Static assets |
Inside a handler, rc.Context() is the same context as the ctx argument, with
the fragment's timeout on it. Use ctx; it is the one already in hand.
Two rules about the render context itself.
- Do not keep it. It belongs to one render. Holding it past the handler's return — in a goroutine, a cache, a struct — is holding on to a request that has finished.
- Do not write to
rc.Page. It is the one registered*collage.Page, shared by every request rendering that page at the same moment. Writing to itsSEOmap or itsDependencyTagsfrom a handler is a data race on live framework state, whichgo test -racereports and production eventually corrupts. Anything that varies per request goes in the data you return or in shared data.
Sharing data between fragments
Fragments on one page often need the same thing. The post page's content, its
<head> and its "more by this author" box all want the post.
rc.Set and collage.Get
rc.Set(key, value) stores a value for the rest of the render, and
collage.Get[T](rc, key) reads it back as the type it was stored as:
// in the parent's handler
rc.Set("post", post)
// in a child's handler, which starts after the parent's has returned
post, ok := collage.Get[Post](rc, "post")
if !ok {
return moreView{}, nil, errors.New("more-by-author: no post in shared data")
}
ok is false when nothing is stored under the key, and also when what is stored is
not a Post — to the caller, both mean the value it wanted is not there. Keys are
your application's own namespace; pick names that will not collide.
rc.Get and rc.Set take the render's lock, which is why they are safe from
concurrent siblings and the bare rc.SharedData map is not.
This works from parent to child, because a parent's handler finishes first. It does not work between siblings: they run at the same time, so one cannot count on the other having set anything yet.
collage.Once
For siblings — or any fragments that might each need the same fetch — use
collage.Once. It runs a fetch at most once per render for a key, and hands the
result to every fragment that asks:
func loadAuthorCard(ctx context.Context, rc *collage.RenderContext) (Author, []string, error) {
slug := rc.Param("slug")
post, err := collage.Once(rc, "post:"+slug, func(ctx context.Context) (Post, error) {
return store.Post(ctx, slug)
})
if err != nil {
return Author{}, nil, err
}
return post.Author, []string{"post:" + slug, "author:" + post.Author.ID}, nil
}
The first caller fetches; the rest wait for it and receive what it produced. The
obvious alternative — rc.Get, fetch on a miss, rc.Set — has a gap between the
check and the write, and two siblings both fall into it and both fetch.
- An error is a result. Everyone waiting gets it; the fetch is not retried per fragment, which is how one slow failure would become several.
- It lasts one render. There is nothing to configure and nothing to evict.
- A waiter whose own context ends stops waiting and returns that error.
- One key, one type. Asking for a key as two different types is
ErrOnceTypeMismatch, not a silently empty value.
collage.Cached
Once shares work within a page. collage.Cached shares it between pages and
between requests: thirty posts by one author fetch the author once.
author, err := collage.Cached(rc, "author:"+id, time.Hour, []string{"author:" + id},
func(ctx context.Context) (Author, error) { return api.Author(ctx, id) })
Its tags are added to the page's own, and invalidating one drops both the stored
value and every cached page built from it. Where nothing is kept across renders —
caching off, development, a preview — it behaves exactly like Once. The details
are in Caching.
Handlers that render nothing
Some fragments exist to declare things for the page rather than to render
markup: a title, a canonical link, structured data. collage.Effect adapts a
handler that returns only an error:
seo := collage.NewFragment("post-seo", "fragments/empty.html").
WithDataHandler(collage.Effect(func(ctx context.Context, rc *collage.RenderContext) error {
post, err := collage.Once(rc, "post:"+rc.Param("slug"), func(ctx context.Context) (Post, error) {
return store.Post(ctx, rc.Param("slug"))
})
if err != nil {
return err
}
rc.HoistTitle(post.Title)
rc.HoistMeta("description", post.Summary)
return nil
})).
Build()
The fragment's template receives no data, and the handler reports no tags. When
what it declares comes from content that changes and the page is cached, that
content's tags still have to reach the page: return them from a
collage.DataHandler instead, or add them with WithDependency on the page.
Timeouts and the context
Every data handler runs under a deadline: the fragment's WithTimeout(d), or
Config.Template.Timeout — five seconds by default — for a fragment that sets
none.
The deadline is on the ctx the handler receives. It bounds the context, not
the handler. The framework does not abandon a handler at its deadline; it waits
for it to return, because a goroutine cannot be stopped from outside, and
abandoning handlers that never return would leak one goroutine per request,
forever. So a handler that ignores its context can run past its deadline, and the
page waits for it.
Honouring it is one habit: pass ctx to everything that can wait.
func loadWeather(ctx context.Context, rc *collage.RenderContext) (Weather, []string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, weatherURL(rc.Locale), nil)
if err != nil {
return Weather{}, nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return Weather{}, nil, err // context.DeadlineExceeded when the timeout passed
}
defer res.Body.Close()
var weather Weather
if err := json.NewDecoder(res.Body).Decode(&weather); err != nil {
return Weather{}, nil, err
}
return weather, nil, nil
}
A handler that returns its context's error when time runs out fails like any
other, with an error that still matches errors.Is(err, context.DeadlineExceeded).
The deadline is only an error if the handler says so: one that ignores ctx and
returns nil late has succeeded, and its data renders. Pair a short timeout with a
fallback on anything that is nice to have, and the page stops waiting on a slow
service at the point you chose.
Panics
A panic in a data handler does not take the process down. It is recovered into a
*collage.PanicError, carrying the panic value and the stack, and the fragment
fails with it like any other error. In development the error page shows the stack;
reach it in your own code with errors.As.