From a5b0eb91d5b5d92cf8fed4d2dcf9363ce34d31cd Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 22 Aug 2026 16:43:32 +0200 Subject: [PATCH 1/3] feat(ui): support connect handlers on page dots --- .agents/skills/jaws/SKILL.md | 13 +- AI.md | 10 + contracts.go | 10 + lib/ui/AI.md | 16 +- lib/ui/example_test.go | 50 ++++ lib/ui/handler.go | 14 +- lib/ui/handler_377_test.go | 506 +++++++++++++++++++++++++++++++++++ 7 files changed, 610 insertions(+), 9 deletions(-) create mode 100644 lib/ui/handler_377_test.go diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 11711a36..e690bdb2 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -94,10 +94,15 @@ an outer HTTP handler to load the data and invoke a newly constructed the page handler when initial rendering needs a Session; `AutoSession` runs at WebSocket upgrade and is too late for initial page state. -`ui.Handler` owns `NewRequest` and exposes no Request setup hook. If a design -depends on `SetConnectFn`, either move that lifecycle into supported HTTP/session -setup or consciously build a custom page handler. A full-document Template is -not a supported workaround. +`ui.Handler` owns `NewRequest` and recognizes `jaws.ConnectHandler` only on its +top-level Dot. It installs `JawsConnect` before page template execution; the +plain GET does not invoke it, and nested Template dots are not inspected. Use +this hook for connect-time transitions, synchronize it with rendering and other +Requests, and dirty the direct dependencies changed by the transition. Other +Request setup requires a custom page handler. A full-document Template is not a +supported workaround. A connection identifies a JaWS-capable client, not +affirmative human intent; use a semantic click action when that distinction +matters. A retained Template update keeps its wrapper Element and Jid, sends new inner HTML, and unregisters/recreates managed descendants. It does not preserve diff --git a/AI.md b/AI.md index 66ad8e25..fbd9272c 100644 --- a/AI.md +++ b/AI.md @@ -116,6 +116,16 @@ The normal page flow has two related HTTP requests: key, claims the pending Request through `UseRequest`, upgrades the connection, and begins event and DOM-update processing. +When the top-level dot passed to `ui.Handler` implements `ConnectHandler`, the +handler installs its `JawsConnect` method on the Request before page template +execution. The page GET installs but does not invoke the callback. An accepted +WebSocket invokes it with the `ConnectFn` lifecycle; nested `ui.Template` dots +are not inspected. An early connection may overlap initial template execution, +so the shared dot and application state must remain concurrency-safe. Mutate +synchronized state and dirty its direct dependencies through the callback +Request. This identifies a JaWS-capable connected client, not affirmative human +intent; use a semantic click action when that distinction matters. + `HeadHTML` does not manage response headers. The bundled client reloads pages restored from the bfcache. diff --git a/contracts.go b/contracts.go index 6b409990..5d3589be 100644 --- a/contracts.go +++ b/contracts.go @@ -111,6 +111,16 @@ type Updater interface { JawsUpdate(elem *Element) } +// ConnectHandler initializes or validates a [Request] after its WebSocket is accepted. +// +// [github.com/linkdata/jaws/lib/ui.Handler] discovers this optional capability +// only on its top-level page dot. JawsConnect has the lifecycle and permitted +// operations described by [ConnectFn]. +type ConnectHandler interface { + // JawsConnect handles rq after its WebSocket is accepted. + JawsConnect(rq *Request) error +} + // ClickHandler handles click events sent from the browser. type ClickHandler interface { // JawsClick is called for non-input-origin browser clicks. diff --git a/lib/ui/AI.md b/lib/ui/AI.md index 488ef4cf..9c68c346 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -85,6 +85,15 @@ should use Go's native template action: {{template "partial" .Dot}} ``` +Immediately after creating each Request, `ui.Handler` inspects only its direct +top-level Dot for `jaws.ConnectHandler` and installs `JawsConnect` before page +template execution. A plain GET only installs the callback; the accepted +WebSocket invokes it with the `jaws.ConnectFn` lifecycle. Nested Template dots +are not inspected. A promoted method satisfies the interface through ordinary +Go method-set rules, but Handler does not recursively inspect embedded values. +An early connection may overlap initial rendering, so the reused Dot and its +callback must synchronize shared state. + The Template's Dot contributes both identity and tags. It must be nil or comparable at runtime, equal to itself, and usable under `tag.TagExpand`. Implementing `JawsGetTag` does not repair a non-comparable Dot because tag @@ -158,9 +167,10 @@ Dirty only the output that actually changed. The bundled client forwards input, click, and context-menu events only while its WebSocket is open and does not replay earlier interaction. When early input -matters, render controls disabled or make the region inert. Use a Request -`ConnectFn` to update a request-local readiness value and dirty its tag or the -exact Element whose updater removes the gate. +matters, render controls disabled or make the region inert. A top-level +`ui.Handler` Dot can implement `jaws.ConnectHandler` to update a readiness value +and dirty its direct tag or the exact Element whose updater removes the gate. +Custom page handlers can install the equivalent Request `ConnectFn` directly. Native form reset is unsupported for managed inputs and Select. A reset button or `form.reset()` changes browser state without the per-control events JaWS diff --git a/lib/ui/example_test.go b/lib/ui/example_test.go index 4b1fb11b..4e645fae 100644 --- a/lib/ui/example_test.go +++ b/lib/ui/example_test.go @@ -6,15 +6,65 @@ import ( "errors" "fmt" "html/template" + "log/slog" + "net/http" "net/http/httptest" "strings" "sync" "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/bind" "github.com/linkdata/jaws/lib/tag" "github.com/linkdata/jaws/lib/ui" ) +const exampleConnectionsHTML = ` +{{$.HeadHTML}} +{{$.Span .Dot.Count}}{{$.TailHTML}} +` + +type exampleConnections struct { + mu sync.RWMutex + count int +} + +// Count returns the accepted connection count as a direct field binding. +func (state *exampleConnections) Count() bind.Binder[int] { + return bind.New(&state.mu, &state.count) +} + +// JawsConnect records an accepted JaWS client connection. +func (state *exampleConnections) JawsConnect(rq *jaws.Request) error { + state.mu.Lock() + state.count++ + state.mu.Unlock() + rq.Dirty(&state.count) + return nil +} + +var _ jaws.ConnectHandler = (*exampleConnections)(nil) + +func ExampleHandler_connectHandler() { + jw, err := jaws.New() + if err != nil { + panic(err) + } + defer jw.Close() + jw.Logger = slog.Default() + + templates := template.Must(template.New("connections").Parse(exampleConnectionsHTML)) + if err = jw.AddTemplateLookuper(templates); err != nil { + panic(err) + } + + go jw.Serve() + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + mux.Handle("GET /", ui.Handler(jw, "connections", new(exampleConnections))) + + _ = mux // serve mux with an HTTP server +} + type examplePathState struct { Title string `json:"title"` Items []string `json:"items"` diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 54fde7c4..42c4461f 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -90,6 +90,9 @@ func (sr *statusRecorder) WriteHeader(code int) { func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { rq := h.NewRequest(w, r) + if handler, ok := h.dot.(jaws.ConnectHandler); ok { + rq.SetConnectFn(handler.JawsConnect) + } sr := &statusRecorder{ResponseWriter: w} rw := RequestWriter{Request: rq, Writer: sr} // Build a fresh per-request pointer so the UI is comparable as a map key @@ -123,8 +126,15 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // response. // // Handler renders without a generated wrapper and does not use dot as a tag. -// Dot may be arbitrary template data. Handler reuses dot across requests; dot -// and its callbacks must support concurrent execution. +// Dot may be arbitrary template data. When dot implements [jaws.ConnectHandler], +// Handler installs its JawsConnect method on each Request before executing the +// page template. The page GET does not invoke JawsConnect. Only the top-level +// dot is inspected; normal method promotion applies, but fields and dots passed +// to nested [Template] values are not inspected recursively. +// +// Handler reuses dot across requests. Dot and its callbacks must support +// concurrent execution, including an early JawsConnect call that overlaps the +// initial page render. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/handler_377_test.go b/lib/ui/handler_377_test.go new file mode 100644 index 00000000..fe9187d1 --- /dev/null +++ b/lib/ui/handler_377_test.go @@ -0,0 +1,506 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/linkdata/jaws" + "github.com/linkdata/jaws/lib/what" + "github.com/linkdata/jaws/lib/wire" +) + +const handler377TestTimeout = 5 * time.Second + +type handler377Server struct { + server *httptest.Server + requests chan *jaws.Request +} + +func newHandler377Server(t *testing.T, source string, dot any, funcs template.FuncMap) (ts *handler377Server) { + t.Helper() + + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + jw.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + + requests := make(chan *jaws.Request, 64) + templateFuncs := template.FuncMap{ + "handler377Capture": func(with With) string { + requests <- with.RequestWriter.Request + return "" + }, + } + for name, fn := range funcs { + templateFuncs[name] = fn + } + tmpl, err := template.New("page").Funcs(templateFuncs).Parse(source) + if err != nil { + jw.Close() + t.Fatal(err) + } + if err = jw.AddTemplateLookuper(tmpl); err != nil { + jw.Close() + t.Fatal(err) + } + + serveDone := make(chan struct{}) + go func() { + defer close(serveDone) + jw.Serve() + }() + + mux := http.NewServeMux() + mux.Handle("GET /jaws/", jw) + mux.Handle("GET /", Handler(jw, "page", dot)) + server := httptest.NewServer(mux) + ts = &handler377Server{server: server, requests: requests} + t.Cleanup(func() { + jw.Close() + server.Close() + <-serveDone + }) + return +} + +func (ts *handler377Server) get(ctx context.Context) (body string, err error) { + var req *http.Request + if req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.server.URL+"/", nil); err == nil { + var resp *http.Response + if resp, err = ts.server.Client().Do(req); err == nil { + var data []byte + var readErr error + data, readErr = io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + err = errors.Join(readErr, closeErr) + body = string(data) + if err == nil && resp.StatusCode != http.StatusOK { + err = fmt.Errorf("GET status = %d, want %d; body %q", resp.StatusCode, http.StatusOK, body) + } + } + } + return +} + +func (ts *handler377Server) dial(ctx context.Context, rq *jaws.Request) (conn *websocket.Conn, err error) { + header := http.Header{} + header.Set("Origin", ts.server.URL) + var resp *http.Response + wsURL := "ws" + strings.TrimPrefix(ts.server.URL, "http") + "/jaws/" + rq.JawsKeyString() + conn, resp, err = websocket.Dial(ctx, wsURL, &websocket.DialOptions{ + HTTPHeader: header, + }) + if err == nil { + if resp == nil { + err = errors.New("WebSocket handshake returned no response") + } else if resp.StatusCode != http.StatusSwitchingProtocols { + err = fmt.Errorf("WebSocket status = %d, want %d", resp.StatusCode, http.StatusSwitchingProtocols) + } + } + if err != nil && conn != nil { + err = errors.Join(err, conn.CloseNow()) + conn = nil + } + return +} + +func handler377Receive[T any](t *testing.T, ctx context.Context, ch <-chan T, description string) (value T) { + t.Helper() + select { + case value = <-ch: + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", description, context.Cause(ctx)) + } + return +} + +func handler377WaitDone(t *testing.T, ctx context.Context, done <-chan struct{}, description string) { + t.Helper() + select { + case <-done: + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", description, context.Cause(ctx)) + } +} + +func handler377CloseConn(t *testing.T, conn *websocket.Conn) { + t.Helper() + if conn != nil { + if err := conn.CloseNow(); err != nil { + t.Errorf("closing WebSocket: %v", err) + } + } +} + +type handler377ConnectRecorder struct { + mu sync.Mutex + calls []*jaws.Request + connectErr error + called chan *jaws.Request +} + +func (rec *handler377ConnectRecorder) JawsConnect(rq *jaws.Request) (err error) { + rec.mu.Lock() + rec.calls = append(rec.calls, rq) + err = rec.connectErr + rec.mu.Unlock() + if rec.called != nil { + rec.called <- rq + } + return +} + +func (rec *handler377ConnectRecorder) snapshot() (calls []*jaws.Request) { + rec.mu.Lock() + calls = append(calls, rec.calls...) + rec.mu.Unlock() + return +} + +type handler377OrderDot struct { + mu sync.Mutex + order []string + connectEntered chan struct{} + releaseConnect <-chan struct{} + clickCalled chan struct{} + enterOnce sync.Once + clickOnce sync.Once +} + +func (dot *handler377OrderDot) JawsConnect(*jaws.Request) error { + dot.mu.Lock() + dot.order = append(dot.order, "connect start") + dot.mu.Unlock() + dot.enterOnce.Do(func() { close(dot.connectEntered) }) + <-dot.releaseConnect + dot.mu.Lock() + dot.order = append(dot.order, "connect return") + dot.mu.Unlock() + return nil +} + +func (dot *handler377OrderDot) JawsClick(*jaws.Element, jaws.Click) error { + dot.mu.Lock() + dot.order = append(dot.order, "click") + dot.mu.Unlock() + dot.clickOnce.Do(func() { close(dot.clickCalled) }) + return nil +} + +func (dot *handler377OrderDot) snapshot() (order []string) { + dot.mu.Lock() + order = append(order, dot.order...) + dot.mu.Unlock() + return +} + +func TestHandler377_DotWithoutConnectHandlerUnchanged(t *testing.T) { + ts := newHandler377Server(t, `{{handler377Capture $}}hello {{.Dot}}`, "world", nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + body, err := ts.get(ctx) + if err != nil { + t.Fatal(err) + } + if body != "hello world" { + t.Fatalf("body = %q, want %q", body, "hello world") + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + if rq.GetConnectFn() != nil { + t.Fatal("plain page dot installed a ConnectFn") + } +} + +func TestHandler377_GETDoesNotInvokeConnectHandler(t *testing.T) { + dot := new(handler377ConnectRecorder) + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + if calls := dot.snapshot(); len(calls) != 0 { + t.Fatalf("JawsConnect calls after GET = %v, want none", calls) + } + if rq.GetConnectFn() == nil { + t.Fatal("GET did not install the page dot ConnectFn") + } +} + +func TestHandler377_WebSocketInvokesConnectHandlerOnceForSameRequest(t *testing.T) { + dot := &handler377ConnectRecorder{called: make(chan *jaws.Request, 2)} + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + if got := handler377Receive(t, ctx, dot.called, "JawsConnect call"); got != rq { + t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) + } + handler377CloseConn(t, conn) + handler377WaitDone(t, ctx, rqCtx.Done(), "Request shutdown") + + calls := dot.snapshot() + if len(calls) != 1 || calls[0] != rq { + t.Fatalf("JawsConnect calls = %v, want [%p]", calls, rq) + } +} + +func TestHandler377_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { + releaseConnect := make(chan struct{}) + release := sync.OnceFunc(func() { close(releaseConnect) }) + defer release() + dot := &handler377OrderDot{ + connectEntered: make(chan struct{}), + releaseConnect: releaseConnect, + clickCalled: make(chan struct{}), + } + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}{{$.Button "run" .Dot}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + elems := rq.GetElements(dot) + if len(elems) != 1 { + t.Fatalf("button Elements = %d, want 1", len(elems)) + } + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + defer func() { handler377CloseConn(t, conn) }() + handler377WaitDone(t, ctx, dot.connectEntered, "JawsConnect entry") + + click := wire.WsMsg{Jid: elems[0].Jid(), What: what.Click, Data: "0 0 0 run"} + if err = conn.Write(ctx, websocket.MessageText, click.Append(nil)); err != nil { + t.Fatal(err) + } + select { + case <-dot.clickCalled: + t.Fatal("browser click ran while JawsConnect was blocked") + default: + } + release() + handler377WaitDone(t, ctx, dot.clickCalled, "browser click") + + want := []string{"connect start", "connect return", "click"} + if got := dot.snapshot(); !slices.Equal(got, want) { + t.Fatalf("callback order = %v, want %v", got, want) + } +} + +func TestHandler377_ConnectHandlerErrorClosesWebSocket(t *testing.T) { + connectErr := errors.New("connect rejected") + dot := &handler377ConnectRecorder{ + connectErr: connectErr, + called: make(chan *jaws.Request, 2), + } + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + defer func() { handler377CloseConn(t, conn) }() + if got := handler377Receive(t, ctx, dot.called, "JawsConnect call"); got != rq { + t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) + } + handler377WaitDone(t, ctx, rqCtx.Done(), "failed Request shutdown") + if !errors.Is(context.Cause(rqCtx), connectErr) { + t.Fatalf("Request cause = %v, want %v", context.Cause(rqCtx), connectErr) + } + if _, _, err = conn.Read(ctx); err == nil { + t.Fatal("ConnectHandler error left WebSocket open") + } else if ctx.Err() != nil { + t.Fatalf("WebSocket remained open until timeout: %v", context.Cause(ctx)) + } +} + +func TestHandler377_ConnectHandlerInstalledBeforeTemplateExecution(t *testing.T) { + type observation struct { + rq *jaws.Request + installed bool + } + observed := make(chan observation, 1) + dot := new(handler377ConnectRecorder) + ts := newHandler377Server(t, `{{handler377Capture $}}{{handler377Expose $}}`, dot, template.FuncMap{ + "handler377Expose": func(with With) string { + rq := with.RequestWriter.Request + observed <- observation{rq: rq, installed: rq.GetConnectFn() != nil} + return rq.JawsKeyString() + }, + }) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + body, err := ts.get(ctx) + if err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + got := handler377Receive(t, ctx, observed, "template observation") + if got.rq != rq { + t.Fatalf("template Request = %p, want page Request %p", got.rq, rq) + } + if !got.installed { + t.Fatal("template exposed the Request key before ConnectFn was installed") + } + if body != rq.JawsKeyString() { + t.Fatalf("exposed key = %q, want %q", body, rq.JawsKeyString()) + } +} + +func TestHandler377_NestedTemplateConnectHandlerIgnored(t *testing.T) { + nested := &handler377ConnectRecorder{called: make(chan *jaws.Request, 2)} + page := struct { + Nested *handler377ConnectRecorder + }{Nested: nested} + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}{{$.Template "div" "nested" .Dot.Nested}}{{define "nested"}}nested{{end}}`, page, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + if _, err := ts.get(ctx); err != nil { + t.Fatal(err) + } + rq := handler377Receive(t, ctx, ts.requests, "page Request") + rqCtx := rq.Context() + if rq.GetConnectFn() != nil { + t.Fatal("nested Template dot installed the Request ConnectFn") + } + conn, err := ts.dial(ctx, rq) + if err != nil { + t.Fatal(err) + } + handler377CloseConn(t, conn) + handler377WaitDone(t, ctx, rqCtx.Done(), "Request shutdown") + if calls := nested.snapshot(); len(calls) != 0 { + t.Fatalf("nested JawsConnect calls = %v, want none", calls) + } +} + +func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { + const requestCount = 8 + dot := &handler377ConnectRecorder{called: make(chan *jaws.Request, requestCount*2)} + ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + defer cancel() + + getResults := make(chan error, requestCount) + startGET := make(chan struct{}) + for range requestCount { + go func() { + <-startGET + _, err := ts.get(ctx) + getResults <- err + }() + } + close(startGET) + for range requestCount { + if err := handler377Receive(t, ctx, getResults, "concurrent GET"); err != nil { + t.Fatal(err) + } + } + + requests := make([]*jaws.Request, 0, requestCount) + requestSet := make(map[*jaws.Request]struct{}, requestCount) + requestContexts := make([]context.Context, 0, requestCount) + for range requestCount { + rq := handler377Receive(t, ctx, ts.requests, "page Request") + if _, duplicate := requestSet[rq]; duplicate { + t.Fatalf("duplicate page Request %p", rq) + } + requestSet[rq] = struct{}{} + requests = append(requests, rq) + requestContexts = append(requestContexts, rq.Context()) + } + + type dialResult struct { + conn *websocket.Conn + err error + } + dialResults := make(chan dialResult, requestCount) + startDial := make(chan struct{}) + for _, rq := range requests { + go func() { + <-startDial + conn, err := ts.dial(ctx, rq) + dialResults <- dialResult{conn: conn, err: err} + }() + } + close(startDial) + + conns := make([]*websocket.Conn, 0, requestCount) + defer func() { + for _, conn := range conns { + handler377CloseConn(t, conn) + } + }() + var dialErr error + for range requestCount { + result := handler377Receive(t, ctx, dialResults, "concurrent WebSocket dial") + if result.conn != nil { + conns = append(conns, result.conn) + } + dialErr = errors.Join(dialErr, result.err) + } + if dialErr != nil { + t.Fatal(dialErr) + } + + calledSet := make(map[*jaws.Request]struct{}, requestCount) + for range requestCount { + rq := handler377Receive(t, ctx, dot.called, "JawsConnect call") + if _, ok := requestSet[rq]; !ok { + t.Errorf("JawsConnect received unknown Request %p", rq) + } + if _, duplicate := calledSet[rq]; duplicate { + t.Errorf("JawsConnect called more than once for Request %p", rq) + } + calledSet[rq] = struct{}{} + } + if calls := dot.snapshot(); len(calls) != requestCount { + t.Fatalf("JawsConnect calls = %d, want %d", len(calls), requestCount) + } + + for _, conn := range conns { + handler377CloseConn(t, conn) + } + conns = nil + for i, rqCtx := range requestContexts { + handler377WaitDone(t, ctx, rqCtx.Done(), fmt.Sprintf("Request %d shutdown", i)) + } +} From 1809b66e20a0eade97930f58d34bf8b9802e8d1c Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 22 Aug 2026 17:12:18 +0200 Subject: [PATCH 2/3] docs(ui): clarify connect handler scope --- .agents/skills/jaws/SKILL.md | 17 ++-- AI.md | 16 ++-- lib/ui/AI.md | 23 ++++-- lib/ui/handler.go | 10 ++- lib/ui/handler_377_test.go | 152 +++++++++++++++++------------------ 5 files changed, 117 insertions(+), 101 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index e690bdb2..c2f6eba4 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -96,13 +96,16 @@ WebSocket upgrade and is too late for initial page state. `ui.Handler` owns `NewRequest` and recognizes `jaws.ConnectHandler` only on its top-level Dot. It installs `JawsConnect` before page template execution; the -plain GET does not invoke it, and nested Template dots are not inspected. Use -this hook for connect-time transitions, synchronize it with rendering and other -Requests, and dirty the direct dependencies changed by the transition. Other -Request setup requires a custom page handler. A full-document Template is not a -supported workaround. A connection identifies a JaWS-capable client, not -affirmative human intent; use a semantic click action when that distinction -matters. +plain GET does not invoke it, and a `ConnectHandler` found only on a nested +Template Dot is ignored without a diagnostic. Use this hook for connect-time +transitions. The bundled client connects after parsing the document, but a +custom client may invoke it while rendering is still in progress, and a reused +Dot can serve other Requests concurrently. Synchronize shared state. An exact +Element dirty target affects its owning Request; an ordinary tag affects matching +Elements on every live Request. Other Request setup requires a custom page +handler. A full-document Template is not a supported workaround. A connection +identifies a JaWS-capable client, not affirmative human intent; use a semantic +click action when that distinction matters. A retained Template update keeps its wrapper Element and Jid, sends new inner HTML, and unregisters/recreates managed descendants. It does not preserve diff --git a/AI.md b/AI.md index fbd9272c..35606a02 100644 --- a/AI.md +++ b/AI.md @@ -119,12 +119,16 @@ The normal page flow has two related HTTP requests: When the top-level dot passed to `ui.Handler` implements `ConnectHandler`, the handler installs its `JawsConnect` method on the Request before page template execution. The page GET installs but does not invoke the callback. An accepted -WebSocket invokes it with the `ConnectFn` lifecycle; nested `ui.Template` dots -are not inspected. An early connection may overlap initial template execution, -so the shared dot and application state must remain concurrency-safe. Mutate -synchronized state and dirty its direct dependencies through the callback -Request. This identifies a JaWS-capable connected client, not affirmative human -intent; use a semantic click action when that distinction matters. +WebSocket invokes it with the `ConnectFn` lifecycle. A `ConnectHandler` found +only on a nested `ui.Template` dot is ignored without a diagnostic. The bundled +client connects after parsing the document. A custom client may dial after +flushed response bytes expose the request key and overlap initial template +execution, so the shared dot and application state must remain concurrency-safe. +After changing state, dirty the exact Element or dependency tag whose scope +matches the transition. An exact Element targets its owning Request; an ordinary +tag updates matching Elements on every live Request. A connection identifies a +JaWS-capable client, not affirmative human intent; use a semantic click action +when that distinction matters. `HeadHTML` does not manage response headers. The bundled client reloads pages restored from the bfcache. diff --git a/lib/ui/AI.md b/lib/ui/AI.md index 9c68c346..e1bad625 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -88,10 +88,12 @@ should use Go's native template action: Immediately after creating each Request, `ui.Handler` inspects only its direct top-level Dot for `jaws.ConnectHandler` and installs `JawsConnect` before page template execution. A plain GET only installs the callback; the accepted -WebSocket invokes it with the `jaws.ConnectFn` lifecycle. Nested Template dots -are not inspected. A promoted method satisfies the interface through ordinary -Go method-set rules, but Handler does not recursively inspect embedded values. -An early connection may overlap initial rendering, so the reused Dot and its +WebSocket invokes it with the `jaws.ConnectFn` lifecycle. A `ConnectHandler` +found only on a nested Template Dot is ignored without a diagnostic. A promoted +method satisfies the interface through ordinary Go method-set rules, but Handler +does not recursively inspect embedded values. The bundled client connects after +parsing the document. A custom client may dial after flushed response bytes +expose the request key and overlap initial rendering, so the reused Dot and its callback must synchronize shared state. The Template's Dot contributes both identity and tags. It must be nil or @@ -167,10 +169,15 @@ Dirty only the output that actually changed. The bundled client forwards input, click, and context-menu events only while its WebSocket is open and does not replay earlier interaction. When early input -matters, render controls disabled or make the region inert. A top-level -`ui.Handler` Dot can implement `jaws.ConnectHandler` to update a readiness value -and dirty its direct tag or the exact Element whose updater removes the gate. -Custom page handlers can install the equivalent Request `ConnectFn` directly. +matters, render controls disabled or make the region inert. In a custom page +handler, install a Request `ConnectFn` that updates synchronized request-local +readiness and dirties the unique request-specific tag registered by the gate, or +the exact Element whose updater removes it. An outer HTTP handler can +equivalently construct a fresh `ui.Handler` and readiness-bearing Dot for each +GET; that Dot's `jaws.ConnectHandler` is then request-local. A reused `ui.Handler` +shares its Dot across Requests, and ordinary tag dirtying updates matching +Elements on every live Request. Treat `ConnectHandler` on that shared Dot as a +shared-state hook, not as a scalar request-local readiness gate. Native form reset is unsupported for managed inputs and Select. A reset button or `form.reset()` changes browser state without the per-control events JaWS diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 42c4461f..45d52dec 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -129,12 +129,14 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Dot may be arbitrary template data. When dot implements [jaws.ConnectHandler], // Handler installs its JawsConnect method on each Request before executing the // page template. The page GET does not invoke JawsConnect. Only the top-level -// dot is inspected; normal method promotion applies, but fields and dots passed -// to nested [Template] values are not inspected recursively. +// dot's method set is inspected; normal method promotion applies. A +// ConnectHandler on a non-promoted field or a dot passed to a nested [Template] +// is ignored without a diagnostic. // // Handler reuses dot across requests. Dot and its callbacks must support -// concurrent execution, including an early JawsConnect call that overlaps the -// initial page render. +// concurrent execution. The bundled client connects after parsing the document. +// A custom client may dial after flushed response bytes expose the request key, +// so JawsConnect can overlap the initial page render. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} } diff --git a/lib/ui/handler_377_test.go b/lib/ui/handler_377_test.go index fe9187d1..2ce24d8f 100644 --- a/lib/ui/handler_377_test.go +++ b/lib/ui/handler_377_test.go @@ -21,14 +21,14 @@ import ( "github.com/linkdata/jaws/lib/wire" ) -const handler377TestTimeout = 5 * time.Second +const handlerWebSocketTestTimeout = 5 * time.Second -type handler377Server struct { +type handlerWebSocketServer struct { server *httptest.Server requests chan *jaws.Request } -func newHandler377Server(t *testing.T, source string, dot any, funcs template.FuncMap) (ts *handler377Server) { +func newHandlerWebSocketServer(t *testing.T, source string, dot any, funcs template.FuncMap) (ts *handlerWebSocketServer) { t.Helper() jw, err := jaws.New() @@ -39,7 +39,7 @@ func newHandler377Server(t *testing.T, source string, dot any, funcs template.Fu requests := make(chan *jaws.Request, 64) templateFuncs := template.FuncMap{ - "handler377Capture": func(with With) string { + "captureHandlerRequest": func(with With) string { requests <- with.RequestWriter.Request return "" }, @@ -67,7 +67,7 @@ func newHandler377Server(t *testing.T, source string, dot any, funcs template.Fu mux.Handle("GET /jaws/", jw) mux.Handle("GET /", Handler(jw, "page", dot)) server := httptest.NewServer(mux) - ts = &handler377Server{server: server, requests: requests} + ts = &handlerWebSocketServer{server: server, requests: requests} t.Cleanup(func() { jw.Close() server.Close() @@ -76,7 +76,7 @@ func newHandler377Server(t *testing.T, source string, dot any, funcs template.Fu return } -func (ts *handler377Server) get(ctx context.Context) (body string, err error) { +func (ts *handlerWebSocketServer) get(ctx context.Context) (body string, err error) { var req *http.Request if req, err = http.NewRequestWithContext(ctx, http.MethodGet, ts.server.URL+"/", nil); err == nil { var resp *http.Response @@ -95,7 +95,7 @@ func (ts *handler377Server) get(ctx context.Context) (body string, err error) { return } -func (ts *handler377Server) dial(ctx context.Context, rq *jaws.Request) (conn *websocket.Conn, err error) { +func (ts *handlerWebSocketServer) dial(ctx context.Context, rq *jaws.Request) (conn *websocket.Conn, err error) { header := http.Header{} header.Set("Origin", ts.server.URL) var resp *http.Response @@ -117,7 +117,7 @@ func (ts *handler377Server) dial(ctx context.Context, rq *jaws.Request) (conn *w return } -func handler377Receive[T any](t *testing.T, ctx context.Context, ch <-chan T, description string) (value T) { +func receiveHandlerWebSocketValue[T any](t *testing.T, ctx context.Context, ch <-chan T, description string) (value T) { t.Helper() select { case value = <-ch: @@ -127,7 +127,7 @@ func handler377Receive[T any](t *testing.T, ctx context.Context, ch <-chan T, de return } -func handler377WaitDone(t *testing.T, ctx context.Context, done <-chan struct{}, description string) { +func waitHandlerWebSocketDone(t *testing.T, ctx context.Context, done <-chan struct{}, description string) { t.Helper() select { case <-done: @@ -136,7 +136,7 @@ func handler377WaitDone(t *testing.T, ctx context.Context, done <-chan struct{}, } } -func handler377CloseConn(t *testing.T, conn *websocket.Conn) { +func closeHandlerWebSocket(t *testing.T, conn *websocket.Conn) { t.Helper() if conn != nil { if err := conn.CloseNow(); err != nil { @@ -145,14 +145,14 @@ func handler377CloseConn(t *testing.T, conn *websocket.Conn) { } } -type handler377ConnectRecorder struct { +type connectHandlerRecorder struct { mu sync.Mutex calls []*jaws.Request connectErr error called chan *jaws.Request } -func (rec *handler377ConnectRecorder) JawsConnect(rq *jaws.Request) (err error) { +func (rec *connectHandlerRecorder) JawsConnect(rq *jaws.Request) (err error) { rec.mu.Lock() rec.calls = append(rec.calls, rq) err = rec.connectErr @@ -163,14 +163,14 @@ func (rec *handler377ConnectRecorder) JawsConnect(rq *jaws.Request) (err error) return } -func (rec *handler377ConnectRecorder) snapshot() (calls []*jaws.Request) { +func (rec *connectHandlerRecorder) snapshot() (calls []*jaws.Request) { rec.mu.Lock() calls = append(calls, rec.calls...) rec.mu.Unlock() return } -type handler377OrderDot struct { +type connectBeforeClickDot struct { mu sync.Mutex order []string connectEntered chan struct{} @@ -180,7 +180,7 @@ type handler377OrderDot struct { clickOnce sync.Once } -func (dot *handler377OrderDot) JawsConnect(*jaws.Request) error { +func (dot *connectBeforeClickDot) JawsConnect(*jaws.Request) error { dot.mu.Lock() dot.order = append(dot.order, "connect start") dot.mu.Unlock() @@ -192,7 +192,7 @@ func (dot *handler377OrderDot) JawsConnect(*jaws.Request) error { return nil } -func (dot *handler377OrderDot) JawsClick(*jaws.Element, jaws.Click) error { +func (dot *connectBeforeClickDot) JawsClick(*jaws.Element, jaws.Click) error { dot.mu.Lock() dot.order = append(dot.order, "click") dot.mu.Unlock() @@ -200,16 +200,16 @@ func (dot *handler377OrderDot) JawsClick(*jaws.Element, jaws.Click) error { return nil } -func (dot *handler377OrderDot) snapshot() (order []string) { +func (dot *connectBeforeClickDot) snapshot() (order []string) { dot.mu.Lock() order = append(order, dot.order...) dot.mu.Unlock() return } -func TestHandler377_DotWithoutConnectHandlerUnchanged(t *testing.T) { - ts := newHandler377Server(t, `{{handler377Capture $}}hello {{.Dot}}`, "world", nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) +func TestHandler_DotWithoutConnectHandlerUnchanged(t *testing.T) { + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}hello {{.Dot}}`, "world", nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() body, err := ts.get(ctx) @@ -219,22 +219,22 @@ func TestHandler377_DotWithoutConnectHandlerUnchanged(t *testing.T) { if body != "hello world" { t.Fatalf("body = %q, want %q", body, "hello world") } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") if rq.GetConnectFn() != nil { t.Fatal("plain page dot installed a ConnectFn") } } -func TestHandler377_GETDoesNotInvokeConnectHandler(t *testing.T) { - dot := new(handler377ConnectRecorder) - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) +func TestHandler_GETDoesNotInvokeConnectHandler(t *testing.T) { + dot := new(connectHandlerRecorder) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() if _, err := ts.get(ctx); err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") if calls := dot.snapshot(); len(calls) != 0 { t.Fatalf("JawsConnect calls after GET = %v, want none", calls) } @@ -243,26 +243,26 @@ func TestHandler377_GETDoesNotInvokeConnectHandler(t *testing.T) { } } -func TestHandler377_WebSocketInvokesConnectHandlerOnceForSameRequest(t *testing.T) { - dot := &handler377ConnectRecorder{called: make(chan *jaws.Request, 2)} - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) +func TestHandler_WebSocketInvokesConnectHandlerOnceForSameRequest(t *testing.T) { + dot := &connectHandlerRecorder{called: make(chan *jaws.Request, 2)} + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() if _, err := ts.get(ctx); err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") rqCtx := rq.Context() conn, err := ts.dial(ctx, rq) if err != nil { t.Fatal(err) } - if got := handler377Receive(t, ctx, dot.called, "JawsConnect call"); got != rq { + if got := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call"); got != rq { t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) } - handler377CloseConn(t, conn) - handler377WaitDone(t, ctx, rqCtx.Done(), "Request shutdown") + closeHandlerWebSocket(t, conn) + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "Request shutdown") calls := dot.snapshot() if len(calls) != 1 || calls[0] != rq { @@ -270,23 +270,23 @@ func TestHandler377_WebSocketInvokesConnectHandlerOnceForSameRequest(t *testing. } } -func TestHandler377_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { +func TestHandler_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { releaseConnect := make(chan struct{}) release := sync.OnceFunc(func() { close(releaseConnect) }) defer release() - dot := &handler377OrderDot{ + dot := &connectBeforeClickDot{ connectEntered: make(chan struct{}), releaseConnect: releaseConnect, clickCalled: make(chan struct{}), } - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}{{$.Button "run" .Dot}}`, dot, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}{{$.Button "run" .Dot}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() if _, err := ts.get(ctx); err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") elems := rq.GetElements(dot) if len(elems) != 1 { t.Fatalf("button Elements = %d, want 1", len(elems)) @@ -295,8 +295,8 @@ func TestHandler377_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { if err != nil { t.Fatal(err) } - defer func() { handler377CloseConn(t, conn) }() - handler377WaitDone(t, ctx, dot.connectEntered, "JawsConnect entry") + defer func() { closeHandlerWebSocket(t, conn) }() + waitHandlerWebSocketDone(t, ctx, dot.connectEntered, "JawsConnect entry") click := wire.WsMsg{Jid: elems[0].Jid(), What: what.Click, Data: "0 0 0 run"} if err = conn.Write(ctx, websocket.MessageText, click.Append(nil)); err != nil { @@ -308,7 +308,7 @@ func TestHandler377_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { default: } release() - handler377WaitDone(t, ctx, dot.clickCalled, "browser click") + waitHandlerWebSocketDone(t, ctx, dot.clickCalled, "browser click") want := []string{"connect start", "connect return", "click"} if got := dot.snapshot(); !slices.Equal(got, want) { @@ -316,30 +316,30 @@ func TestHandler377_ConnectHandlerRunsBeforeBrowserMessages(t *testing.T) { } } -func TestHandler377_ConnectHandlerErrorClosesWebSocket(t *testing.T) { +func TestHandler_ConnectHandlerErrorClosesWebSocket(t *testing.T) { connectErr := errors.New("connect rejected") - dot := &handler377ConnectRecorder{ + dot := &connectHandlerRecorder{ connectErr: connectErr, called: make(chan *jaws.Request, 2), } - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() if _, err := ts.get(ctx); err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") rqCtx := rq.Context() conn, err := ts.dial(ctx, rq) if err != nil { t.Fatal(err) } - defer func() { handler377CloseConn(t, conn) }() - if got := handler377Receive(t, ctx, dot.called, "JawsConnect call"); got != rq { + defer func() { closeHandlerWebSocket(t, conn) }() + if got := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call"); got != rq { t.Fatalf("JawsConnect Request = %p, want page Request %p", got, rq) } - handler377WaitDone(t, ctx, rqCtx.Done(), "failed Request shutdown") + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "failed Request shutdown") if !errors.Is(context.Cause(rqCtx), connectErr) { t.Fatalf("Request cause = %v, want %v", context.Cause(rqCtx), connectErr) } @@ -350,29 +350,29 @@ func TestHandler377_ConnectHandlerErrorClosesWebSocket(t *testing.T) { } } -func TestHandler377_ConnectHandlerInstalledBeforeTemplateExecution(t *testing.T) { +func TestHandler_ConnectHandlerInstalledBeforeTemplateExecution(t *testing.T) { type observation struct { rq *jaws.Request installed bool } observed := make(chan observation, 1) - dot := new(handler377ConnectRecorder) - ts := newHandler377Server(t, `{{handler377Capture $}}{{handler377Expose $}}`, dot, template.FuncMap{ - "handler377Expose": func(with With) string { + dot := new(connectHandlerRecorder) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{exposeHandlerRequestKey $}}`, dot, template.FuncMap{ + "exposeHandlerRequestKey": func(with With) string { rq := with.RequestWriter.Request observed <- observation{rq: rq, installed: rq.GetConnectFn() != nil} return rq.JawsKeyString() }, }) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() body, err := ts.get(ctx) if err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") - got := handler377Receive(t, ctx, observed, "template observation") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") + got := receiveHandlerWebSocketValue(t, ctx, observed, "template observation") if got.rq != rq { t.Fatalf("template Request = %p, want page Request %p", got.rq, rq) } @@ -384,19 +384,19 @@ func TestHandler377_ConnectHandlerInstalledBeforeTemplateExecution(t *testing.T) } } -func TestHandler377_NestedTemplateConnectHandlerIgnored(t *testing.T) { - nested := &handler377ConnectRecorder{called: make(chan *jaws.Request, 2)} +func TestHandler_NestedTemplateConnectHandlerIgnored(t *testing.T) { + nested := &connectHandlerRecorder{called: make(chan *jaws.Request, 2)} page := struct { - Nested *handler377ConnectRecorder + Nested *connectHandlerRecorder }{Nested: nested} - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}{{$.Template "div" "nested" .Dot.Nested}}{{define "nested"}}nested{{end}}`, page, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}{{$.Template "div" "nested" .Dot.Nested}}{{define "nested"}}nested{{end}}`, page, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() if _, err := ts.get(ctx); err != nil { t.Fatal(err) } - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") rqCtx := rq.Context() if rq.GetConnectFn() != nil { t.Fatal("nested Template dot installed the Request ConnectFn") @@ -405,18 +405,18 @@ func TestHandler377_NestedTemplateConnectHandlerIgnored(t *testing.T) { if err != nil { t.Fatal(err) } - handler377CloseConn(t, conn) - handler377WaitDone(t, ctx, rqCtx.Done(), "Request shutdown") + closeHandlerWebSocket(t, conn) + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), "Request shutdown") if calls := nested.snapshot(); len(calls) != 0 { t.Fatalf("nested JawsConnect calls = %v, want none", calls) } } -func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { +func TestHandler_SharedHandlerSupportsConcurrentRequests(t *testing.T) { const requestCount = 8 - dot := &handler377ConnectRecorder{called: make(chan *jaws.Request, requestCount*2)} - ts := newHandler377Server(t, `{{handler377Capture $}}{{$.HeadHTML}}`, dot, nil) - ctx, cancel := context.WithTimeout(t.Context(), handler377TestTimeout) + dot := &connectHandlerRecorder{called: make(chan *jaws.Request, requestCount*2)} + ts := newHandlerWebSocketServer(t, `{{captureHandlerRequest $}}{{$.HeadHTML}}`, dot, nil) + ctx, cancel := context.WithTimeout(t.Context(), handlerWebSocketTestTimeout) defer cancel() getResults := make(chan error, requestCount) @@ -430,7 +430,7 @@ func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { } close(startGET) for range requestCount { - if err := handler377Receive(t, ctx, getResults, "concurrent GET"); err != nil { + if err := receiveHandlerWebSocketValue(t, ctx, getResults, "concurrent GET"); err != nil { t.Fatal(err) } } @@ -439,7 +439,7 @@ func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { requestSet := make(map[*jaws.Request]struct{}, requestCount) requestContexts := make([]context.Context, 0, requestCount) for range requestCount { - rq := handler377Receive(t, ctx, ts.requests, "page Request") + rq := receiveHandlerWebSocketValue(t, ctx, ts.requests, "page Request") if _, duplicate := requestSet[rq]; duplicate { t.Fatalf("duplicate page Request %p", rq) } @@ -466,12 +466,12 @@ func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { conns := make([]*websocket.Conn, 0, requestCount) defer func() { for _, conn := range conns { - handler377CloseConn(t, conn) + closeHandlerWebSocket(t, conn) } }() var dialErr error for range requestCount { - result := handler377Receive(t, ctx, dialResults, "concurrent WebSocket dial") + result := receiveHandlerWebSocketValue(t, ctx, dialResults, "concurrent WebSocket dial") if result.conn != nil { conns = append(conns, result.conn) } @@ -483,7 +483,7 @@ func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { calledSet := make(map[*jaws.Request]struct{}, requestCount) for range requestCount { - rq := handler377Receive(t, ctx, dot.called, "JawsConnect call") + rq := receiveHandlerWebSocketValue(t, ctx, dot.called, "JawsConnect call") if _, ok := requestSet[rq]; !ok { t.Errorf("JawsConnect received unknown Request %p", rq) } @@ -497,10 +497,10 @@ func TestHandler377_SharedHandlerSupportsConcurrentRequests(t *testing.T) { } for _, conn := range conns { - handler377CloseConn(t, conn) + closeHandlerWebSocket(t, conn) } conns = nil for i, rqCtx := range requestContexts { - handler377WaitDone(t, ctx, rqCtx.Done(), fmt.Sprintf("Request %d shutdown", i)) + waitHandlerWebSocketDone(t, ctx, rqCtx.Done(), fmt.Sprintf("Request %d shutdown", i)) } } From 900b32a50e2cf6e6f1ef3a2908000cd774cb3107 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Sat, 22 Aug 2026 17:21:20 +0200 Subject: [PATCH 3/3] docs(ui): tighten connect handler guidance --- .agents/skills/jaws/SKILL.md | 22 ++++++++++------------ AI.md | 23 ++++++++++++----------- contracts.go | 2 +- lib/ui/AI.md | 32 +++++++++++++++----------------- lib/ui/handler.go | 14 +++++++------- 5 files changed, 45 insertions(+), 48 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index c2f6eba4..c9cd927c 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -94,18 +94,16 @@ an outer HTTP handler to load the data and invoke a newly constructed the page handler when initial rendering needs a Session; `AutoSession` runs at WebSocket upgrade and is too late for initial page state. -`ui.Handler` owns `NewRequest` and recognizes `jaws.ConnectHandler` only on its -top-level Dot. It installs `JawsConnect` before page template execution; the -plain GET does not invoke it, and a `ConnectHandler` found only on a nested -Template Dot is ignored without a diagnostic. Use this hook for connect-time -transitions. The bundled client connects after parsing the document, but a -custom client may invoke it while rendering is still in progress, and a reused -Dot can serve other Requests concurrently. Synchronize shared state. An exact -Element dirty target affects its owning Request; an ordinary tag affects matching -Elements on every live Request. Other Request setup requires a custom page -handler. A full-document Template is not a supported workaround. A connection -identifies a JaWS-capable client, not affirmative human intent; use a semantic -click action when that distinction matters. +`ui.Handler` owns `NewRequest` and recognizes `jaws.ConnectHandler` in its +top-level Dot's method set, including promoted methods. It installs `JawsConnect` +before page template execution; the plain GET does not invoke it. An +implementation available only on a nested Template Dot is ignored without a +diagnostic. The bundled client connects after parsing the document. A custom +client can invoke the hook during rendering once flushed response bytes expose +the request key. Other Request setup requires a custom page handler. A +full-document Template is not a supported workaround. A connection identifies a +JaWS-capable client, not affirmative human intent; use a semantic click action +when that distinction matters. A retained Template update keeps its wrapper Element and Jid, sends new inner HTML, and unregisters/recreates managed descendants. It does not preserve diff --git a/AI.md b/AI.md index 35606a02..803050bb 100644 --- a/AI.md +++ b/AI.md @@ -118,17 +118,18 @@ The normal page flow has two related HTTP requests: When the top-level dot passed to `ui.Handler` implements `ConnectHandler`, the handler installs its `JawsConnect` method on the Request before page template -execution. The page GET installs but does not invoke the callback. An accepted -WebSocket invokes it with the `ConnectFn` lifecycle. A `ConnectHandler` found -only on a nested `ui.Template` dot is ignored without a diagnostic. The bundled -client connects after parsing the document. A custom client may dial after -flushed response bytes expose the request key and overlap initial template -execution, so the shared dot and application state must remain concurrency-safe. -After changing state, dirty the exact Element or dependency tag whose scope -matches the transition. An exact Element targets its owning Request; an ordinary -tag updates matching Elements on every live Request. A connection identifies a -JaWS-capable client, not affirmative human intent; use a semantic click action -when that distinction matters. +execution. The page GET only installs the callback; an accepted WebSocket +invokes it with the `ConnectFn` lifecycle. Only the top-level dot's method set is +considered, including promoted methods. An implementation available only on a +nested `ui.Template` dot is ignored without a diagnostic. The bundled client +connects after parsing the document, while a custom client can dial once flushed +response bytes expose the request key and overlap initial template execution. +Because `ui.Handler` reuses the dot, its state and callbacks must be +concurrency-safe. + +After changing state, use the exact-Element or dependency-tag scope described +above. A connection identifies a JaWS-capable client, not affirmative human +intent; use a semantic click action when that distinction matters. `HeadHTML` does not manage response headers. The bundled client reloads pages restored from the bfcache. diff --git a/contracts.go b/contracts.go index 5d3589be..646f1a59 100644 --- a/contracts.go +++ b/contracts.go @@ -117,7 +117,7 @@ type Updater interface { // only on its top-level page dot. JawsConnect has the lifecycle and permitted // operations described by [ConnectFn]. type ConnectHandler interface { - // JawsConnect handles rq after its WebSocket is accepted. + // JawsConnect initializes or validates rq. JawsConnect(rq *Request) error } diff --git a/lib/ui/AI.md b/lib/ui/AI.md index e1bad625..a76ee5a8 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -85,16 +85,15 @@ should use Go's native template action: {{template "partial" .Dot}} ``` -Immediately after creating each Request, `ui.Handler` inspects only its direct -top-level Dot for `jaws.ConnectHandler` and installs `JawsConnect` before page -template execution. A plain GET only installs the callback; the accepted -WebSocket invokes it with the `jaws.ConnectFn` lifecycle. A `ConnectHandler` -found only on a nested Template Dot is ignored without a diagnostic. A promoted -method satisfies the interface through ordinary Go method-set rules, but Handler -does not recursively inspect embedded values. The bundled client connects after -parsing the document. A custom client may dial after flushed response bytes -expose the request key and overlap initial rendering, so the reused Dot and its -callback must synchronize shared state. +After creating each Request, `ui.Handler` checks the top-level Dot's method set, +including promoted methods, for `jaws.ConnectHandler` and installs `JawsConnect` +before page template execution. A plain GET only installs the callback; the +accepted WebSocket invokes it with the `jaws.ConnectFn` lifecycle. An +implementation available only on a nested Template Dot is ignored without a +diagnostic. Handler reuses its Dot across Requests, so its state and callbacks +must support concurrent execution. The bundled client connects after parsing +the document, while a custom client can dial once flushed response bytes expose +the request key and overlap initial rendering. The Template's Dot contributes both identity and tags. It must be nil or comparable at runtime, equal to itself, and usable under `tag.TagExpand`. @@ -171,13 +170,12 @@ The bundled client forwards input, click, and context-menu events only while its WebSocket is open and does not replay earlier interaction. When early input matters, render controls disabled or make the region inert. In a custom page handler, install a Request `ConnectFn` that updates synchronized request-local -readiness and dirties the unique request-specific tag registered by the gate, or -the exact Element whose updater removes it. An outer HTTP handler can -equivalently construct a fresh `ui.Handler` and readiness-bearing Dot for each -GET; that Dot's `jaws.ConnectHandler` is then request-local. A reused `ui.Handler` -shares its Dot across Requests, and ordinary tag dirtying updates matching -Elements on every live Request. Treat `ConnectHandler` on that shared Dot as a -shared-state hook, not as a scalar request-local readiness gate. +readiness and dirties the request-specific readiness tag registered by the +gate, or the exact Element whose updater removes it. A reused `ui.Handler` +shares its Dot across Requests. Its `ConnectHandler` can validate the callback +Request or update synchronized shared state, but a scalar Dot field cannot serve +as a request-local readiness gate. Ordinary tag dirtying updates matching +Elements on every live Request. Native form reset is unsupported for managed inputs and Select. A reset button or `form.reset()` changes browser state without the per-control events JaWS diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 45d52dec..36ecc69b 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -129,14 +129,14 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Dot may be arbitrary template data. When dot implements [jaws.ConnectHandler], // Handler installs its JawsConnect method on each Request before executing the // page template. The page GET does not invoke JawsConnect. Only the top-level -// dot's method set is inspected; normal method promotion applies. A -// ConnectHandler on a non-promoted field or a dot passed to a nested [Template] -// is ignored without a diagnostic. +// dot's method set is considered, including promoted methods. Implementations +// available only through non-promoted fields or nested [Template] dots are +// ignored without a diagnostic. // -// Handler reuses dot across requests. Dot and its callbacks must support -// concurrent execution. The bundled client connects after parsing the document. -// A custom client may dial after flushed response bytes expose the request key, -// so JawsConnect can overlap the initial page render. +// Handler reuses dot across requests, so dot and its callbacks must support +// concurrent execution. The bundled client connects after parsing the document, +// while a custom client can dial once flushed response bytes expose the request +// key and overlap the initial page render. func Handler(jw *jaws.Jaws, name string, dot any) http.Handler { return uiHandler{Jaws: jw, name: name, dot: dot} }