Door
doors.Door is the main primitive for dynamic page regions in Doors.
A Door lets you render a place in the page now and change just that place later from handlers, subscriptions, or doors.Go(...). It is the tool you reach for when you do not want a whole-page reroute.
Doors are often stored on struct fields when the same Door needs to be reused:
type Panel struct {
body doors.Door
}
Rendering
There are two common ways to render a Door.
Proxy
Use ~>(door) when you want a real element in the template to become the Door container:
elem (p *Panel) Main() {
~>(p.body) <div class="panel">
Initial content
</div>
}
This mounts the Door and seeds it with that subtree.
If the proxied element has no inner content, Doors uses the Door's current stored content instead:
elem (p *Panel) Main() {
~>(p.body) <div class="panel"></div>
}
A single Door can only be mounted in one place at a time. If you render the same Door again somewhere else, Doors removes the previous mounted copy from the HTML and the new render becomes the active one.
Current State
Use ~(&door) when you want to render the Door's current state directly:
elem (p *Panel) Main() {
~~
p.body.Outer(ctx, <div>Prepared before mount</div>)
~~
~(&p.body)
}
This is useful when the Door was prepared earlier and you just want to place it on the page.
Containers
Every mounted Door needs a DOM container.
- With
~>(door) <tag>...</tag>, your tag becomes that container. - With
~>(door) <>...</>, Doors creates its own container element. - With
~(&door), Doors uses the last container from the internal state, or creates its own.
By default, the generated container tag is d0-r, and Doors styles it with display: contents, so it usually does not affect layout.
Use an explicit tag with ~>(door) when the exact HTML parent matters.
Methods
Inner(ctx context.Context, content any)
Outer(ctx context.Context, outer any)
Static(ctx context.Context, content any)
DeferredInner(ctx context.Context, content any)
DeferredOuter(ctx context.Context, outer any)
DeferredStatic(ctx context.Context, content any)
Reload(ctx context.Context)
Unmount(ctx context.Context)
Inner
Inner replaces the Door's children while keeping the current Door container mounted.
p.body.Inner(ctx, <div id="updated">Updated</div>)
Use this when the region should stay mounted and only its contents should change.
Passing nil empties the Door while keeping it available for later changes.
Outer
Outer replaces the rendered Door with a new outer element while keeping the same Go Door handle live.
Use it when you need to change the root element, attributes, or wrapper structure and still update the Door later.
p.body.Outer(ctx, <section class="panel is-open">Updated shell</section>)
Like Inner, outer accepts any renderable value — an element, a component, a string — not only markup.
Static
Static makes the Door static: the content replaces the dynamic element on the page. The target Door loses control over the region, is unmounted, and can be garbage collected.
Later method calls still update the Door's stored state for a future render, but they do not automatically put that Door back into the current DOM.
p.body.Static(ctx, <div id="done">Done</div>)
Passing nil removes the mounted Door without rendering replacement content.
Static content may mount new Doors of its own — that makes Static the building block for unbounded feeds and streams, see Growing Content.
Static content belongs to the Door's parent. Hooks, subscriptions, doors.Go(...), and lifecycle callbacks registered while rendering it attach to the parent and live until the parent's content is cleared, even if the static render fails. Calls it makes during render, such as doors.Call(...), go through the parent too and are sent even if the static render fails.
Reload
Reload re-renders the Door's current content.
Use it when the stored content depends on outside state and you want to redraw without swapping in new content.
Unmount
Unmount removes the Door from the DOM but keeps its current content for a future mount.
Deferred Operations
DeferredInner, DeferredOuter, and DeferredStatic work like Inner, Outer, and Static, but wait until the Door's current content is rendered and scheduled for delivery. Use them to show a placeholder first and replace it with content that is slow to render:
func (p *Page) showReport(ctx context.Context, period string) {
p.report.Inner(ctx, <div class="skeleton">Loading report...</div>)
p.report.DeferredInner(ctx, ReportTable{period: period})
}
A handler does not wait for deferred content: its indicator, After actions, and OnSettle finish without it.
In a Bind or Route
Each operation also exists as a package function that targets the Door ctx renders in:
doors.DeferredInner(ctx context.Context, content any) <-chan error
doors.DeferredOuter(ctx context.Context, content any) <-chan error
doors.DeferredStatic(ctx context.Context, content any) <-chan error
Content in a Bind or route uses them to defer its own slow part:
elem (p *Page) Main() {
~(p.period.Bind(elem(period string) {
<section class="report">
~(if doors.IsDocument(ctx) {
~ReportTable{period: period}
} else {
~~
doors.DeferredInner(ctx, ReportTable{period: period})
~~
<div class="skeleton">Loading report...</div>
})
</section>
}))
}
type ReportTable struct {
period string
}
elem (t ReportTable) Main() {
~~
rows := loadReport(ctx, t.period)
~~
<table>
~(for _, row := range rows {
<tr>
<td>~(row.Name)</td>
<td>~(row.Total)</td>
</tr>
})
</table>
}
doors.IsDocument is true only while rendering the page response, so the page arrives complete instead of as a skeleton followed by an update.
- Keep the placeholder inside an element that stays, like the
<section>here; to replace that element too, useDeferredOuter. - Outside a
Bindor route, give the loader a Door of its own, for example~>(new(doors.Door)) <section class="report">. - Do the expensive work inside the heavy content's render, as
ReportTabledoes, not while building the argument.
Growing Content
Content passed to Static may itself mount new Doors. That turns Static into an append primitive built on three ideas:
- Edge handoff. Keep one Door as the growth edge. To append, replace the edge with the new item plus a fresh edge. The old edge is released: the page accumulates without bound, while the server keeps one live Door per chain.
- Pre-mount buffering. Door methods work on an unmounted Door by writing its stored state, so appends may arrive before the feed ever renders — the chain builds up behind its head Door, and the first render reproduces it whole. Render mounts the chain's head; appends target the edge.
- The feed is render-once. Appended items go to the page and are forgotten — the server does not keep them. Consuming the seed drops the last reference to the chain, so a later render cannot restore the items and starts a fresh chain; when the feed must survive an ancestor re-render, render past items from your data source.
type Feed struct {
mu sync.Mutex
seed *doors.Door
tail *doors.Door
}
func (f *Feed) Append(ctx context.Context, item gox.Elem) {
f.mu.Lock()
defer f.mu.Unlock()
// append before the first render — seed a head door
// that will hold the chain until it mounts
if f.tail == nil {
f.seed = new(doors.Door)
f.tail = f.seed
}
// replace the edge with the item plus a fresh edge
next := new(doors.Door)
f.tail.Static(ctx, <>
~(item)
~(next)
</>)
f.tail = next
}
elem (f *Feed) Main() {
~~
f.mu.Lock()
defer f.mu.Unlock()
// consume the seed — the chain renders once,
// then the server forgets it
head := f.seed
f.seed = nil
// no seed — start a fresh chain from this edge
if head == nil {
head = new(doors.Door)
f.tail = head
}
~~
<section class="feed">~(head)</section>
}
One mutex guards seed and tail across both paths — Door methods are safe from any goroutine, but the fields tracking the chain are yours. Holding the lock across the whole render body is fine: a Door's content render is scheduled on the runtime, not performed inline, so the lock stays short.
The edge's position sets the growth direction: edge after the item grows the chain down (append), edge before the item grows it up (prepend).
For items that stay dynamic for a while — streamed output, edit-in-place entries — mount the item's dynamic part through its own Door, and call Static on that Door with the final content when the item is done. Keep the final content free of hooks and subscriptions: static content registers them on the parent, where they live as long as the feed does.
To pace a fast producer, read the completion channel Static returns before appending the next item — see Completion Channels.
Inside <table>, <select>, and other tag-restricted parents, the generated d0-r container is invalid and browsers foster-parent it out. Give each edge a real tag when creating it, and render it as usual:
next := new(doors.Door)
next.Outer(ctx, <tr></tr>)
Once
A Door stores the content it is given until that content is replaced. A large value passed to Inner or Outer therefore stays in memory for as long as the Door holds it.
doors.Once(v) renders v on the first render only and drops its reference to it, so the Door keeps an empty shell instead of the content:
p.list.Outer(ctx, doors.Once(&UserList{users: users}))
The trade-off: a Door rendered again, for example by Reload or an ancestor re-render, comes back empty. Use Once for content that is rendered and then left alone, or replaced with fresh content on every update.
Hooks keep what their handlers capture, so a component whose methods serve as handlers stays in memory until its hooks are removed.
Completion Channels
Each mutating method returns a completion channel. The return value is optional to use:
Inner(ctx context.Context, content any) <-chan error
Outer(ctx context.Context, outer any) <-chan error
Static(ctx context.Context, content any) <-chan error
DeferredInner(ctx context.Context, content any) <-chan error
DeferredOuter(ctx context.Context, outer any) <-chan error
DeferredStatic(ctx context.Context, content any) <-chan error
Reload(ctx context.Context) <-chan error
Unmount(ctx context.Context) <-chan error
The channel first sends one value once the operation is scheduled: nil when the content rendered, or an error. On a detached context a second value follows: nil once the update is applied to the page, or an error. Then the channel closes. On any other context the channel closes right after the first value; whether the update was applied is not reported. See Detached Context.
A failed render sends its error as the first value, see Render Errors. An error of context.Canceled means the operation was overwritten by a newer Door operation, unmount, or related lifecycle change.
A closed channel with no value means the Door was not mounted by the time the operation was observed. A channel that closes without a further value means the operation was accepted, but its outcome is not reported: the Door is not mounted yet, or the context is not detached.
In most cases, ignore the channel. There is nothing to check: whether an update lands depends on the lifetime of what it targets, and that is managed for you.
Never wait on the channel during render or in beam subscriptions. If you need the result, such as pacing a fast stream of updates, wait in a goroutine of your own with doors.DetachedContext(ctx), or in doors.Go(...) when the work should follow a rendered subtree, and read both values:
go func(ctx context.Context) {
ch := f.tail.Static(ctx, Item{entry: entry})
<-ch // scheduled
if err, ok := <-ch; ok && err == nil {
// on the page
}
}(doors.DetachedContext(ctx))
See Detached Context.
Render Errors
Render errors are a marginal case. Element render functions do not return errors unless you return one yourself, and that is rarely right: loading failures, missing data, and other render states belong in the page, so handle them and render them as content. This section covers what happens when an error does come back.
When rendering a Door's content fails, nothing is sent to the browser, and the error is logged as door rendering error with the call site. A render that fails because it was superseded is not logged.
- A Door operation sends the error on its completion channel. The page keeps the previous content, but it no longer responds. The next successful operation replaces it. After a failed
Outer,Innerfails until a newOutersucceeds or the parent renders the Door again. A failedStaticstill makes the Door static: later operations only update its stored state. - A Door rendered by its parent, on the initial page or in a parent update, is left out of the parent's output. Operations on it fail with a placement error until the parent renders it again.
Lifecycle
A Door has two sides:
- stored state on the
doors.Doorvalue itself - mounted state on the page
That explains most of its behavior:
- a new Door starts unmounted
- you can still call methods on it before it is rendered
- when the Door is later rendered, it mounts its saved state unless you overwrite it by proxying a container with content, like
~>(door) <div>This content will overwrite whatever was stored in the Door</div> - while mounted, later changes are synchronized to the browser DOM
This means:
Innerbefore mount stores children that will appear laterInner(ctx, nil)before mount stores an empty DoorOuterbefore mount stores a new outer elementStaticbefore mount stores static content instead of a live Door containerStatic(ctx, nil)before mount stores an absent stateUnmountremoves the Door now but keeps its content for a later mount
After a Door has been made static or unmounted, later calls still update the Door's stored state. They do not automatically put that Door back into the DOM, but they do affect what will happen if the Door is rendered again later.
Lifecycle Hooks
doors.OnReady(ctx context.Context, f func(ctx context.Context))
doors.OnSettle(ctx context.Context, on func(ctx context.Context), ops ...func(ctx context.Context))
doors.OnBatch(ctx context.Context, on func(ctx context.Context), ops ...func(ctx context.Context))
doors.OnClean(ctx context.Context, f func())
OnClean runs when the surrounding content goes away. Use it to free resources tied to that content: it is the lighter alternative to a goroutine that waits on ctx.Done():
elem (c Chat) Main() {
~~
sub := c.hub.Subscribe()
doors.OnClean(ctx, func() {
sub.Close()
})
~~
<div class="chat">Live</div>
}
OnReady runs once the surrounding content is rendered and on its way to the browser. Use it for work that must come after that markup. Its ctx is detached, see Detached Context.
OnSettle runs once everything a handler started has its updates queued. Use it to act after a multi-step update. Its ctx is not detached: operations made with it stay with the handler's updates, so do not block in it. If the handler hands work to a goroutine, keep it open with doors.HoldSettle; with Batch set, the updates stay unapplied until release:
release := doors.HoldSettle(ctx)
go func() {
defer release()
// operations started with the handler ctx join the batch
}()
A goroutine that outlives the handler without HoldSettle should use doors.DetachedContext(ctx).
OnBatch batches updates yourself, anywhere: the updates made in ops are applied at once, and on runs when the browser has applied them. on runs with a detached ctx; ops do not. Where OnSettle reports that the updates are queued for delivery, OnBatch reports that they are on the page:
doors.OnBatch(ctx, func(ctx context.Context) {
p.status.Inner(ctx, "Saved")
}, func(ctx context.Context) {
p.form.Inner(ctx, OrderForm{order: order})
p.list.Inner(ctx, OrderList{status: status})
})
In a handler with Batch set, OnBatch joins the handler's batch instead of opening a new one. In a handler the batch closes when the handler settles, not when ops return; use doors.DetachedContext(ctx) to batch on its own. A hook's completion, its indicator and After actions, waits for batches started in its OnSettle callback.
Details:
OnClean,OnSettle, andOnBatchalways run, exactly once;OnReadyis best-effort.- Do not block in these callbacks. For slow teardown, start a goroutine with a context captured beforehand, for example
doors.InstanceContext(ctx).
Use Cases
- Use
Innerwhen the Door should stay in place and only its contents should change. - Use
Inner(ctx, nil)when the Door should stay alive but become empty. - Use
Outerwhen you need a new root element but want to keep the same Door handle. - Use
Outer(ctx, nil)when you need mounted placeholder that does not affect layout (d0-r) - Use
Staticwhen the region's content is final and the Door's live container is no longer needed. - Use
Static(ctx, nil)when the Door should disappear without replacement content. - Use
DeferredInner,DeferredOuter, orDeferredStaticto show a placeholder while slow content renders; adddoors.IsDocumentto send it complete in the page response. - Use
Reloadwhen you want to redraw the current content. - Use
Unmountwhen the Door should disappear for now but keep its internal state for reuse. - Use
doors.Oncewhen the Door should not keep its content in memory after rendering it. - Use a
Staticchain when the page should accumulate items — feeds, logs, chats, streams — while the server keeps only the growth edge.