Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,62 @@ version and include migration notes.

## [Unreleased]

### Added

- `host.WithSSCResumeAuthorizer(func(*http.Request, *host.Session) error)`, run
before a retained session is reattached. An authenticated deployment decides
whether the upgrade request may resume the session its token names; refusing
serves the caller a new session and leaves the retained one untouched. Left
unset, resume keeps its token-only behavior.
- `router.ResultGuard` and `Route.ResultGuards`, guards that return
`router.Allow()`, `router.Forbid()`, `router.RedirectTo(path)` or
`router.ReplaceWith(path)` instead of a bool. A login redirect is now
declarative and replaces the history entry rather than pushing one, so back
does not return to the denied page. `Route.Guards`, `router.Page` and the
`Group` builder are unchanged; a route may declare both, and its bool guards
run first. New errors: `router.ErrNavigationForbidden` and
`router.ErrInvalidGuardResult`. `RedirectTo` and `ReplaceWith` are SPA
navigation: their destination goes through the route matcher and never leaves
the application.
- `router.HostReplace(path)`, the result guard outcome for a page the host owns
rather than the SPA: a server-rendered login or session-expiry page. The
browser loads the path as a document with `location.replace`, so it replaces
the current history entry and back does not return to the refused route. The
guard chain ends there: no loader, component, unmount, history entry, DOM
write or route state commit runs for the protected route, and the page stays
as it is until the browser unloads it. The target must already be a rooted
same-origin path, validated as the guard returned it and never trimmed into
shape, against browser URL normalization (control characters, surrounding
whitespace, backslash authorities, scheme-relative and malformed targets fail
closed with `router.ErrInvalidGuardResult`); cross-origin handoff is
intentionally unsupported. Outside
browser builds `NavigateContext` returns a `*router.HostNavigationError`
carrying the target and wrapping the new `router.ErrHostNavigation`, and
commits nothing.
- `router.Revalidate()` and `router.RevalidateContext(ctx)`, which re-run the
guards of the route that is already mounted against the committed path and
apply their decision to it. An application whose session authority changes
installs the newer snapshot and then revalidates, instead of teaching each
page to police itself: the guards stay declarative and keep their existing
order and parameters. An allowed route is a true no-op; `Forbid()` and a
`Route.Guards` guard that now blocks unmount the routed component with its
scope and host registrations, clear its subtree and drop the data and
metadata it committed, leaving the `MountRoot` shell and the outlet mounted
and the committed path in place; `RedirectTo` and `ReplaceWith` replace the
refused history entry and route normally; `HostReplace` keeps its handoff
contract. With nothing mounted for the
committed path it does nothing, so repeating it after a refusal is a no-op.
- `core.SetHostRegistrar`, the cleanup-capable form of `core.SetHostRegister`.
Both remain; the legacy hook registers without a release.
- `hostclient.RegisterComponentOwned`, the cleanup-capable form of
`hostclient.RegisterComponent`. It returns an idempotent release that owns the
binding's lifecycle; `RegisterComponent` keeps its signature and its
register-until-replaced behavior.
- `state.Signal.SetFromHostGated` and `state.Signal.HostWriteBarrier`, the pair
a host delivery uses to make its ownership check and its write one step
against a concurrent revocation. `SetFromHost` is unchanged and is the ungated
form of the same write.

### Changed

- WebAssembly applications can opt into structured `rfw:render-trace` browser
Expand Down Expand Up @@ -95,6 +151,44 @@ version and include migration notes.

### Fixed

- release a host component binding when its client component unmounts.
`AddHostComponent` registered the binding on every render and never undid it,
so leaving a route kept the `hostclient` binding, its queued reconnect init
and the server subscription alive, and a late push still reached the
unmounted component root. Registration now happens once per mounted
lifecycle, its cleanup is owned by the component, and a remount registers
again; a component that is rendered but never mounted binds nothing, so a
preparatory render cannot leave a binding no unmount would release. No
published signature changes: `hostclient.RegisterComponent` keeps
its own, and the cleanup comes from the new `RegisterComponentOwned`, which
`hostclient` installs into `core.SetHostRegistrar`.
- deliver host frames only to the binding that is still registered, and only to
the root it owns. Delivery snapshotted a binding, released the lock and then
wrote to the DOM, so a frame that overlapped a route exit could still update a
released component; it also resolved its target through `dom.ComponentRoot`,
whose fallback would have written into `#app` once the component's own root
left the document. Delivery is now serialized against registration and
release, revalidates the registration it was snapshotted for, and ignores a
frame whose `[data-component-id]` root is absent. `RegisterComponent` takes
any id, so the id is escaped through `CSS.escape` before it reaches a
selector, and compared attribute by attribute where that API is missing.
- stop a host frame from writing the signals of a released component. Delivery
re-checked the registration and then ran the setters with no lock held, so a
cleanup that returned in between still saw a stale frame set a host signal and
schedule the effects that follow one after the unmount. A registration now
hands every frame it carries a gate the signal evaluates under the same lock
it stores the value with, and the cleanup closes that gate before waiting out
the writes it had already allowed: once the cleanup returns, neither the root
it owned nor its host signals take another frame. No lock covers application
code, so a setter that unmounts or remounts its own component from inside the
frame cannot deadlock against its own registration.
- keep the disconnected message queue bounded across offline route churn. Every
mount and unmount appended its own reconnect control, so a route entered and
left 100 times offline retained 100 unsubscribes. The queue now holds one
control per host component, the latest desired state: a registration replaces
a queued unsubscribe and a release replaces a queued init, in the position the
superseded control held. Other messages, and the controls of other components,
keep their order.
- isolate SSC broadcasts behind bounded per-connection outbound queues and a
write deadline. A slow socket no longer stalls later broadcast targets; a
full queue requests resynchronization and closes that connection so retained
Expand Down
23 changes: 22 additions & 1 deletion core/hosthook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@

package core

// HostRegistrar registers a component with the SSC runtime and returns an
// idempotent cleanup that releases the registration. A nil cleanup means the
// registration owns nothing to release.
type HostRegistrar func(id, name string, vars []string) func()

// hostRegisterComponent registers a component with the SSC runtime. It stays
// nil until the hostclient package is linked, so a client-only build never
// reaches the websocket and net/http stacks through core.
var hostRegisterComponent func(id, name string, vars []string)
var hostRegisterComponent HostRegistrar

// SetHostRegister installs the SSC component registration hook. The hostclient
// package calls it from its own init; applications never call it directly.
// Registrations made through it are never released, so prefer
// SetHostRegistrar when the runtime can unbind a component.
func SetHostRegister(fn func(id, name string, vars []string)) {
if fn == nil {
hostRegisterComponent = nil
return
}
hostRegisterComponent = func(id, name string, vars []string) func() {
fn(id, name, vars)
return nil
}
}

// SetHostRegistrar installs a cleanup-capable SSC registration hook, so a
// component that unmounts can release its binding, pending initialization and
// server subscription.
func SetHostRegistrar(fn HostRegistrar) {
hostRegisterComponent = fn
}
217 changes: 204 additions & 13 deletions core/hosthook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,34 @@ package core

import "testing"

// Rendering a component that declares host components goes through the hook
// the hostclient package installs. Without hostclient linked the hook is nil
// and rendering must still succeed, which is what keeps client-only builds
// free of the websocket and net/http stacks.
func TestHostRegisterHook(t *testing.T) {
// hostCall is one registration the hook observed.
type hostCall struct {
id, name string
vars []string
}

// recordHostCalls installs the legacy hook and collects what it receives.
func recordHostCalls(t *testing.T, calls *[]hostCall) {
t.Helper()
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })

type call struct {
id, name string
vars []string
}
var calls []call
SetHostRegister(func(id, name string, vars []string) {
calls = append(calls, call{id: id, name: name, vars: vars})
*calls = append(*calls, hostCall{id: id, name: name, vars: vars})
})
}

// Mounting a component that declares host components goes through the hook the
// hostclient package installs, once per declared name and in declaration order.
func TestHostRegisterHook(t *testing.T) {
var calls []hostCall
recordHostCalls(t, &calls)

c := NewHTMLComponent("HookHost", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.AddHostComponent("Clock")
c.Init(nil)
c.Render()
c.Mount()

if len(calls) != 2 {
t.Fatalf("expected 2 host registrations, got %d: %+v", len(calls), calls)
Expand All @@ -37,15 +44,199 @@ func TestHostRegisterHook(t *testing.T) {
}
}

// A nil hook is the client-only case: rendering must not panic.
// Ownership is mounted-bound: a component that is only rendered registers
// nothing, because no Unmount would ever come to release it.
func TestHostRegisterSkipsRenderOnlyComponent(t *testing.T) {
var calls []hostCall
recordHostCalls(t, &calls)

c := NewHTMLComponent("RenderOnlyHost", []byte(`<root>{h:value}</root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
for i := 0; i < 5; i++ {
c.RenderFresh()
}
c.Unmount()

if len(calls) != 0 {
t.Fatalf("a render-only component registered %d host bindings: %+v", len(calls), calls)
}
}

// Render before mount is the framework's own order: the render discovers the
// host variables and the mount binds once with them.
func TestHostRegisterAfterRenderThenMount(t *testing.T) {
var calls []hostCall
recordHostCalls(t, &calls)

c := NewHTMLComponent("RenderThenMountHost", []byte(`<root>{h:value}</root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
c.Render()
c.Mount()
c.Render()

if len(calls) != 1 {
t.Fatalf("host registrations = %d, want 1: %+v", len(calls), calls)
}
if len(calls[0].vars) != 1 || calls[0].vars[0] != "value" {
t.Fatalf("registered vars = %v, want the ones the render discovered", calls[0].vars)
}
}

// Mount before render binds at mount time, with whatever the template has
// discovered so far, and the render that follows adds no second registration.
func TestHostRegisterAfterMountThenRender(t *testing.T) {
var calls []hostCall
recordHostCalls(t, &calls)

c := NewHTMLComponent("MountThenRenderHost", []byte(`<root>{h:value}</root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
c.Mount()
if len(calls) != 1 {
t.Fatalf("mount registrations = %d, want 1: %+v", len(calls), calls)
}
if len(calls[0].vars) != 0 {
t.Fatalf("registered vars = %v, want none discovered yet", calls[0].vars)
}
c.Render()
c.RenderFresh()
if len(calls) != 1 {
t.Fatalf("host registrations = %d after rendering, want 1: %+v", len(calls), calls)
}
}

// A nil hook is the client-only case: the lifecycle must not panic.
func TestHostRegisterHookNil(t *testing.T) {
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })
hostRegisterComponent = nil

c := NewHTMLComponent("NoHook", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
if got := c.Render(); got == "" {
t.Fatal("expected a rendered template with no host hook installed")
}
c.Mount()
c.Unmount()
}

// The legacy hook returns no cleanup. Unmounting a component that used it must
// still complete instead of calling a nil release.
func TestLegacyHostRegisterUnmountsWithoutCleanup(t *testing.T) {
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })
registrations := 0
SetHostRegister(func(string, string, []string) { registrations++ })

c := NewHTMLComponent("LegacyHook", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
c.Mount()
c.Render()
c.Unmount()

if registrations != 1 {
t.Fatalf("host registrations = %d, want 1", registrations)
}
}

// A host component is bound once per mounted lifecycle: repeated renders reuse
// the live binding instead of re-initializing the SSC feed.
func TestHostRegistrarBindsOncePerMountedLifecycle(t *testing.T) {
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })
registrations := 0
releases := 0
SetHostRegistrar(func(string, string, []string) func() {
registrations++
return func() { releases++ }
})

c := NewHTMLComponent("RenderedHost", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
c.Mount()
for i := 0; i < 5; i++ {
c.RenderFresh()
}

if registrations != 1 || releases != 0 {
t.Fatalf("registrations = %d, releases = %d, want 1 and 0", registrations, releases)
}
}

// A remount whose render comes straight from the template cache still owns its
// bindings: the markup is identical, the previous registration is not.
func TestHostRegistrarRebindsOnCachedRemount(t *testing.T) {
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })
registrations := 0
releases := 0
SetHostRegistrar(func(string, string, []string) func() {
registrations++
return func() { releases++ }
})

c := NewHTMLComponent("CachedHost", []byte(`<root>{h:value}</root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
c.Mount()
first := c.Render()
c.Unmount()

if registrations != 1 || releases != 1 {
t.Fatalf("first cycle: registrations = %d, releases = %d, want 1 each", registrations, releases)
}

c.Mount()
if second := c.Render(); second != first {
t.Fatalf("the remount re-rendered instead of using the cache:\n%q\n%q", second, first)
}
if registrations != 2 || releases != 1 {
t.Fatalf("cached remount: registrations = %d, releases = %d, want 2 and 1", registrations, releases)
}
c.Unmount()
if releases != 2 {
t.Fatalf("releases = %d after the second unmount, want 2", releases)
}
}

// Route exit must not retain a host binding. Every mount owns its own
// registration and releases exactly that one on unmount, including when the
// render comes from the template cache.
func TestHostRegistrarReleasedOnEveryUnmount(t *testing.T) {
prev := hostRegisterComponent
t.Cleanup(func() { hostRegisterComponent = prev })
active := map[string]int{}
registrations := 0
releases := 0
SetHostRegistrar(func(_, name string, _ []string) func() {
registrations++
active[name]++
return func() {
releases++
active[name]--
}
})

c := NewHTMLComponent("CycledHost", []byte(`<root></root>`), nil)
c.AddHostComponent("Counter")
c.Init(nil)
for cycle := 0; cycle < 100; cycle++ {
c.Mount()
c.Render()
if active["Counter"] != 1 {
t.Fatalf("cycle %d: active bindings = %d, want 1", cycle, active["Counter"])
}
c.Unmount()
if active["Counter"] != 0 {
t.Fatalf("cycle %d: binding retained after unmount", cycle)
}
}
if registrations != 100 || releases != 100 {
t.Fatalf("registrations = %d, releases = %d, want 100 each", registrations, releases)
}
}
Loading
Loading