From 7772ccac80ddaff8ec888b477d2b3ea26968f6ba Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 21 Aug 2026 13:19:35 +0200 Subject: [PATCH 1/3] fix(ui): prevent stale page key caching --- .agents/skills/jaws/SKILL.md | 4 ++++ AI.md | 7 +++++++ lib/ui/AI.md | 5 ++++- lib/ui/handler.go | 6 +++++- lib/ui/handler_content_type_test.go | 22 ++++++++++++++++++++++ request.go | 4 ++++ 6 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 5b129a95..8a4eadd3 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -450,6 +450,10 @@ Guideline: - Ensure pages provide the configured JaWS resources and Request key metadata; `HeadHTML` is the usual way to emit them. +- Include the `no-store` Cache-Control directive on every page response containing + `HeadHTML` or equivalent Request-key metadata; the key is a one-use capability. + `ui.Handler` sets this automatically, while custom page handlers must set it + explicitly. - `TailHTML` is optional; it applies queued attr/class updates before the WebSocket connects and can reduce initial flicker. - Register the JaWS `/jaws/` route prefix correctly and pair request creation with `UseRequest` handling. diff --git a/AI.md b/AI.md index dfb1502c..bbe0b275 100644 --- a/AI.md +++ b/AI.md @@ -115,6 +115,13 @@ 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. +Page responses containing `HeadHTML` or equivalent Request-key metadata must +include the `no-store` Cache-Control directive. An HTTP-cached copy would repeat +the consumed one-use capability and cannot establish another WebSocket +connection. `ui.Handler` sets `Cache-Control: no-store` automatically; custom +page handlers must set it explicitly. A bfcache restoration is handled +separately by the bundled client's `pageshow` reload. + Applications that emit equivalent resources and metadata need not call `HeadHTML` or `TailHTML`. Custom routers may parse the trailing key with `key.Parse`, call `UseRequest`, return 404 when it is absent, and then call the diff --git a/lib/ui/AI.md b/lib/ui/AI.md index d8d4a9b9..9823a096 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -76,7 +76,10 @@ the dot callback returns an attribute with the same name. When dot implements each wrapper's initial render; equal Template values may therefore render different Element-specific attributes without storing them in the Template. The callback is not invoked during `Template.JawsUpdate`. Full page templates -belong in `ui.Handler`; static structural inclusion should use Go's native +belong in `ui.Handler`, which marks every response `Cache-Control: no-store` so +an HTTP cache does not reuse a one-use Request key emitted by +`Request.HeadHTML`. Custom page handlers that emit `Request.HeadHTML` must +include the same directive. Static structural inclusion should use Go's native template action: ```gotemplate diff --git a/lib/ui/handler.go b/lib/ui/handler.go index 9fe64c89..e5fb5c46 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -89,6 +89,7 @@ func (sr *statusRecorder) WriteHeader(code int) { } func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") rq := h.NewRequest(r) sr := &statusRecorder{ResponseWriter: w} rw := RequestWriter{Request: rq, Writer: sr} @@ -120,7 +121,10 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // lookupers and rendered with a [With] value as the template data, exposing // dot through its Dot field. Unless the response already has a Content-Type, // Handler sets it to "text/html; charset=utf-8" when rendering writes its first -// bytes. A render failure before any output retains [http.Error]'s text response. +// bytes. Handler sets Cache-Control to "no-store" so an HTTP cache does not reuse +// responses containing the one-use request key emitted by +// [jaws.Request.HeadHTML]. A render failure before any output retains +// [http.Error]'s text response. // // Handler renders without a generated wrapper and does not use dot as a tag, so // dot may be arbitrary template data. The handler reuses dot across requests; diff --git a/lib/ui/handler_content_type_test.go b/lib/ui/handler_content_type_test.go index e27ff143..52b105b3 100644 --- a/lib/ui/handler_content_type_test.go +++ b/lib/ui/handler_content_type_test.go @@ -62,6 +62,25 @@ func TestHandler_PreservesExplicitContentType(t *testing.T) { } } +func TestHandler_SetsCacheControlNoStore(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + if err = jw.AddTemplateLookuper(template.Must(template.New("page").Parse("
hello
"))); err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + rr.Header().Set("Cache-Control", "public, max-age=3600") + Handler(jw, "page", nil).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + if got, want := rr.Header().Get("Cache-Control"), "no-store"; got != want { + t.Fatalf("Cache-Control = %q, want %q", got, want) + } +} + func TestHandler_RenderFailureBeforeOutputUsesTextContentType(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -79,6 +98,9 @@ func TestHandler_RenderFailureBeforeOutputUsesTextContentType(t *testing.T) { if got, want := rr.Header().Get("Content-Type"), "text/plain; charset=utf-8"; got != want { t.Fatalf("Content-Type = %q, want %q", got, want) } + if got, want := rr.Header().Get("Cache-Control"), "no-store"; got != want { + t.Fatalf("Cache-Control = %q, want %q", got, want) + } } func TestHandler_EmptyWriteBeforeErrorReturns500(t *testing.T) { diff --git a/request.go b/request.go index fe8ea01d..1abef500 100644 --- a/request.go +++ b/request.go @@ -453,6 +453,10 @@ func (rq *Request) releaseBuffersLocked() (buffers *requestBuffers) { } // HeadHTML writes the configured resources and Request key metadata for the page head. +// +// An HTTP response containing this output must include the "no-store" +// Cache-Control directive. The metadata includes a one-use Request key; reusing +// it from an HTTP cache cannot establish another WebSocket connection. func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.mu.RLock() jawsKey := rq.JawsKey From cb79e97ca6e4fce1610604cea6e8c393e5a2f223 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 21 Aug 2026 13:29:03 +0200 Subject: [PATCH 2/3] docs(ui): clarify cache-control override --- lib/ui/handler.go | 8 ++++---- ...ndler_content_type_test.go => handler_headers_test.go} | 0 request.go | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) rename lib/ui/{handler_content_type_test.go => handler_headers_test.go} (100%) diff --git a/lib/ui/handler.go b/lib/ui/handler.go index e5fb5c46..581b4473 100644 --- a/lib/ui/handler.go +++ b/lib/ui/handler.go @@ -121,10 +121,10 @@ func (h uiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // lookupers and rendered with a [With] value as the template data, exposing // dot through its Dot field. Unless the response already has a Content-Type, // Handler sets it to "text/html; charset=utf-8" when rendering writes its first -// bytes. Handler sets Cache-Control to "no-store" so an HTTP cache does not reuse -// responses containing the one-use request key emitted by -// [jaws.Request.HeadHTML]. A render failure before any output retains -// [http.Error]'s text response. +// bytes. Handler always sets Cache-Control to "no-store", replacing any value +// already present, so an HTTP cache does not reuse responses containing the +// one-use request key emitted by [jaws.Request.HeadHTML]. A render failure before +// any output retains [http.Error]'s text response. // // Handler renders without a generated wrapper and does not use dot as a tag, so // dot may be arbitrary template data. The handler reuses dot across requests; diff --git a/lib/ui/handler_content_type_test.go b/lib/ui/handler_headers_test.go similarity index 100% rename from lib/ui/handler_content_type_test.go rename to lib/ui/handler_headers_test.go diff --git a/request.go b/request.go index 1abef500..f6e81847 100644 --- a/request.go +++ b/request.go @@ -455,8 +455,9 @@ func (rq *Request) releaseBuffersLocked() (buffers *requestBuffers) { // HeadHTML writes the configured resources and Request key metadata for the page head. // // An HTTP response containing this output must include the "no-store" -// Cache-Control directive. The metadata includes a one-use Request key; reusing -// it from an HTTP cache cannot establish another WebSocket connection. +// Cache-Control directive. The lib/ui Handler sets it automatically. The +// metadata includes a one-use Request key; a copy replayed from an HTTP cache +// cannot establish another WebSocket connection. func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.mu.RLock() jawsKey := rq.JawsKey From a56dc49e7e4d0fa0e7c3b16990f52223c4989c9b Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 21 Aug 2026 13:34:14 +0200 Subject: [PATCH 3/3] docs: link HeadHTML cache guidance --- request.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/request.go b/request.go index f6e81847..c84c51c5 100644 --- a/request.go +++ b/request.go @@ -455,9 +455,9 @@ func (rq *Request) releaseBuffersLocked() (buffers *requestBuffers) { // HeadHTML writes the configured resources and Request key metadata for the page head. // // An HTTP response containing this output must include the "no-store" -// Cache-Control directive. The lib/ui Handler sets it automatically. The -// metadata includes a one-use Request key; a copy replayed from an HTTP cache -// cannot establish another WebSocket connection. +// Cache-Control directive. [github.com/linkdata/jaws/lib/ui.Handler] sets it +// automatically. The metadata includes a one-use Request key; a copy replayed +// from an HTTP cache cannot establish another WebSocket connection. func (rq *Request) HeadHTML(w io.Writer) (err error) { rq.mu.RLock() jawsKey := rq.JawsKey