Skip to content

v0.18 Grace

Latest

Choose a tag to compare

@derstruct derstruct released this 26 Oct 15:15

Doors 0.18 Release Notes — "Grace"

Doors 0.18 brings two main things:

  • Call batching. A view that changes in several places changes once. Mark a handler Batch: true and the browser applies its updates in one step, while rendering stays parallel and non-blocking.
  • Nested routing. Sections, tabs, and variants fit in one route call and one container, however deep. Every route is complete as soon as it is written.

Highlights

Call batching

Every Door renders on its own and its update goes to the browser as soon as it is ready. That keeps pages responsive, and it shows when one action changes several places or replaces content that takes time to produce: the page changes piece by piece, a list refreshes before its counter, a view empties out and fills in.

Set Batch: true on a link (ALink) or on an event attr (AClick, ASubmit, AKeyDown, AHook, and the rest, next to Race), and the browser holds the handler's updates and applies them together when the handler finishes. The handler runs and renders exactly as before; only the paint is grouped. A link to a view that takes a moment to load keeps the current view intact until the new one is complete, then replaces it at once. A list, its counter, and its toolbar change in the same step:

~>doors.AClick{
	Batch: true,
	On: func(ctx context.Context, _ doors.RequestPointer) bool {
		p.list.Inner(ctx, OrderList{status: status})
		p.count.Inner(ctx, OrderCount{status: status})
		return false
	},
} <button>Show ~(status)</button>

The batch covers everything the handler starts with its context: Door updates, the state propagation they trigger, and path changes. Other calls are not held up by it unless they touch a Door the batch holds or content the batch is about to place; those land right after it, in order.

To batch updates yourself, anywhere, use doors.OnBatch(ctx, on, ops...): the updates made in ops are applied at once, and on runs when the browser has applied them. In a handler with Batch set, OnBatch joins the handler's batch instead of opening a new one:

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})
})

Completion channels deliver a result only on a detached context (DetachedContext, InstanceContext, Go); on other contexts, including a handler ctx used from a goroutine, they close once the call is scheduled. doors.Call with Into needs the second value. To react after the batch, use OnBatch. Title and meta updates apply on arrival and are not batched; when held calls reach SolitairePending, the oldest batch is applied early. See Core Concepts and Door.

Nested routes in one container

A route call used to choose one branch, and each further level was another route call inside the branch content, with its own container. Now the levels nest in one call:

<>
	~(doors.Route(
		doors.RouteModel[Path](
			doors.RouteMatch(
				func(p Path) bool {
					return p.Section == SectionDashboard
				},
				doors.RouteComp[Path](Dashboard{}),
			),
			doors.RouteMatch(
				func(p Path) bool {
					return p.Section == SectionSettings
				},
				doors.RouteBeamDerive(
					func(path doors.Beam[Path]) doors.Beam[string] {
						return doors.DeriveBeam(
							path,
							func(p Path) string {
								return p.Tab
							},
						)
					},
					doors.RouteBeamValue("profile", doors.RouteComp[string](ProfileTab{})),
					doors.RouteBeamValue("team", doors.RouteComp[string](TeamTab{})),
				),
			),
		),
		doors.RouteComp[doors.Location](NotFound{}),
	))
</>

Going from /dashboard to /settings/team, or between tabs, replaces the container content once.

Leaves always match and act as the fallback of their list:

  • RouteComp[T](comp) renders a fixed component
  • RouteBind(render) renders from the value and again whenever it changes
  • RouteSource(render) hands its content the writable source
  • RouteBeam(render) hands its content a read-only beam

Groups match when their condition holds and one of their children matches:

  • RouteMatch(pred, routes...) and RouteValue(value, routes...)
  • RouteDerive(derive, routes...) routes its children on a derived source, usually inside a RouteMatch
  • RouteModel[M](routes...) routes its children on a path model decoded from the Location

Each group has a read-only twin: RouteBeamMatch, RouteBeamValue, RouteBeamDerive, RouteBeamModel. See State and Routing.

Fall-through

A group whose children match nothing falls through to its next sibling. A URL that decodes into a path model but matches none of the routes inside its RouteModel reaches the 404 that follows.

Equal derived values

While a RouteDerive value stays equal by its comparator, the routes below it are not checked again, the same rule beams use to suppress equal updates. A URL change that decodes into an equal model doesn't reach the routes inside a RouteModel.

App-wide context values

doors.WithValue(key, value) makes ctx.Value(key) return the value in every context Doors creates: the page factory, renders, handlers, detached, instance and session contexts, and requests, including the *http.Request a hook or resource handler receives. A database pool or a service client reaches every component and handler without props. A value set for the same key inside Doors, with context.WithValue or doors.Ctx, still wins. doors.WithFunc(f) runs f once per app in NewApp and applies the option it returns, so one option passed to several apps creates a separate value for each.

type dbKey struct{}

func DB(ctx context.Context) *sql.DB {
	return ctx.Value(dbKey{}).(*sql.DB)
}

app := doors.NewApp(page, doors.WithValue(dbKey{}, db))

See Configuration.

Cached resource URLs in code

doors.NewResource(ctx, resource, contentType) returns the cached URL of a static resource, the one the cache attribute produces, so scripts and islands can load stylesheets, images, or fonts by URL. See Resources.

Portable resource URLs

Private resource URLs need the browser session cookie, so they fail when something other than the page fetches them: an Android WebView hands downloads to the system download manager, which sends no cookies, and apps or in-app browsers open links outside the page's cookie jar. The portable attribute gives a resource served through a hook (handler, hook, and proxy sources, private or nocache static resources, scripts and stylesheets) a URL that carries its own access:

<a href=(doors.ResourceHook(serveReport)) portable name="report.pdf" download>
	Download report
</a>

The URL is a bearer link: anyone holding it can load the resource while the content that rendered it lives; it stops working when that content is cleared, the page instance or session ends, or a one-shot hook returns true. Requests through it create no session, and the handler sees the page's session like any hook. portable works with private and nocache; with cache it is an error. Handler-backed sources also accept private, which changes nothing for them. See Resources.

Behavior Changes

  • One container per call. Source.Route, Beam.RouteBeam, and doors.Route render a single Door however deep the routes nest, and RouteBind leaves no longer add a Door of their own. CSS that relied on the extra wrappers needs updating.
  • Branch selection across levels. A chosen branch stays mounted while every condition on its path holds, even if an earlier route would now match too. When one condition fails, the whole tree selects again from the top, as on the first render. A showing leaf gives way to an earlier route that starts matching; a leaf inside a RouteDerive counts as the derive's fallback.
  • RouteValue uses the routed source's comparator instead of == and accepts any T. A source with a nil comparator never matches a RouteValue; route it with RouteMatch.
  • Derive functions run once per render of the route container, for every RouteDerive and RouteBeamDerive in the tree, shown or not.
  • Hook completion waits for the hook's own page updates. The indicator release, the scope release, and the $hook promise now wait for updates the hook made if they are still queued in the browser.
  • OnSettle and hook completion track the handler's own instance only. Updates the handler triggers in other instances, such as other tabs through shared state, are no longer awaited.
  • No goroutine per child context. context.WithCancel and context.AfterFunc on a Doors context (handlers, detached and instance contexts, subscriptions) no longer start a goroutine each.
  • Hook handlers see the path after the hook URL. For a NewHook URL or a handler resource, r.URL.Path is the part after the hook URL, starting with /, instead of the full /~/... path; r.RequestURI keeps the original and the Doors-Hook-Prefix header holds the hook URL. An http.ServeMux can route sub-paths of a hook, see Custom Endpoints. Handlers that parsed the full path need updating.
  • Hook calls carry their order in the D0-Track header instead of the t query parameter, so a t parameter of your own reaches the handler.
  • $hook(name) without an argument sends null.
  • OnSettle and OnBatch accept a nil on.

Breaking Changes

Old New
RouteDefaultComp[T](comp) RouteComp[T](comp)
RouteDefault(render) RouteSource(render)
RouteDefaultBeam(render) RouteBeam(render)
RouteDefaultBind(render) RouteBind(render)
RouteMatch(pred).Comp/.Source/.Beam/.Bind(...) RouteMatch(pred, leaf)
RouteValue(v).Comp/.Source/.Beam/.Bind(...) RouteValue(v, leaf)
RouteDerive(f).Source(set, render) RouteMatch(pred, RouteDerive(derive, RouteSource(render))), with the lens built by DeriveSource
RouteDerive(f).Beam(render), .Bind(render) RouteBeamMatch(pred, RouteBeamDerive(derive, leaf)), with the beam built by DeriveBeam
RouteDeriveEqual(f, equal) DeriveSourceEqual or DeriveBeamEqual inside the derive function
RouteModel(render) RouteModel[M](RouteSource(render)), or routes on M inside it
RouteModelBeam(render) RouteBeamModel[M](RouteBeam(render))
RouteSource[T], RouteBeam[T] route types SourceRoute[T], BeamRoute[T]
MatchRoute, DeriveRoute builders Removed
Render functions returning any gox.Comp type Return gox.Elem

Migration

go get github.com/doors-dev/doors@v0.18.0

Batching is opt-in; handlers that do not set Batch behave as before. Routing needs the changes below.

Wrap each branch's content in a leaf and pass it to its group; the fallback becomes a plain leaf at the end:

<>
	~(doors.Route(
		doors.RouteModel[Path](doors.RouteSource(Page)),
		doors.RouteComp[doors.Location](NotFound{}),
	))
</>

A RouteDerive built from func(T1) (T2, bool) splits into a match for the bool and a derive for the value:

<>
	~(location.Route(
		doors.RouteMatch(
			func(l doors.Location) bool {
				_, ok := parseCustom(l)
				return ok
			},
			doors.RouteDerive(
				func(s doors.Source[doors.Location]) doors.Source[Custom] {
					return doors.DeriveSource(
						s,
						func(l doors.Location) Custom {
							c, _ := parseCustom(l)
							return c
						},
						setCustom,
					)
				},
				doors.RouteSource(CustomPage),
			),
		),
	))
</>

Two more things to check:

  • Routers inside pages. A page that calls Route again inside its own content keeps working once its routes are converted. To render everything in one container instead, move those routes into the parent Route call as a group, as in the example above.
  • RouteValue on sources made with NewSourceEqual, DeriveSourceEqual, or DeriveBeamEqual. RouteValue now matches whenever that comparator calls the values equal. With a nil comparator it never matches; use RouteMatch with == in the predicate instead.