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
3 changes: 3 additions & 0 deletions broadcast.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ func (jw *Jaws) distributeDirt() int {
}

// Reload requests all active [Request] values to reload their current page.
//
// Each WebSocket closes after sending Reload. See [Jaws.Broadcast] for the
// processing-loop requirement.
func (jw *Jaws) Reload() {
jw.Broadcast(wire.Message{
What: what.Reload,
Expand Down
3 changes: 3 additions & 0 deletions doc/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ root, replace a subtree, or delete an object property.

## Connection loss

A server-requested reload stops connection recovery before reloading the page.
It does not show the connection-loss indicator.

After a WebSocket failure, the client waits five seconds before probing
`/jaws/.ping`. A successful probe reloads a page whose navigation age is at least
60 seconds. Otherwise, the client shows a connection-loss indicator and retries
Expand Down
4 changes: 4 additions & 0 deletions doc/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ Browser event handlers run serially within each Request, concurrently with updat
processing and handlers in other Requests. Synchronize shared application state
and return promptly.

Queued browser commands wake the Request processing loop. Maintenance also wakes
Requests with queued output. State-driven rendering still uses `Dirty` and the
normal update interval.

An event accepted before its target is removed can still reach that deleted
Element. Its render, update, and queue helpers become no-ops. Browser events
while disconnected are not replayed; see [browser integration](browser.md).
Expand Down
6 changes: 6 additions & 0 deletions doc/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ cookie. See
[session.go](../session.go) for these contracts. `Jaws.Close` invalidates all
Sessions and clears their data.

`Request.Reload` queues a reload command for one page. The WebSocket writer sends
it and closes the connection, cancelling the Request's context. Events and
callbacks run normally until disconnection. Pending pages send the reload after
successful connection setup, including their connection callback. Session data
and cookies are preserved.

`MaxSessions` limits registered Sessions globally. `MaxSessionsPerIP` limits a
client address bucket. Both default to zero, which disables the respective
limit. A lower per-IP cap reserves capacity for other buckets. Replacement needs
Expand Down
11 changes: 5 additions & 6 deletions doc/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ defer func() {
<-drained
}()

tr.BcastCh <- wire.Message{What: what.Update} // Flush queued initial adjustments.
synctest.Wait()
time.Sleep(jaws.DefaultUpdateInterval + time.Millisecond)
synctest.Wait()
Expand All @@ -179,11 +178,11 @@ synctest.Wait() // Let the dirty pass and collector finish.
// Inspect records here, while the other goroutines are blocked.
```

The unaddressed Update wakes the Request to flush initial render output;
`synctest.Wait` supplies synchronization. Fake time then crosses the normal
dirty-update interval without a real-time delay. This settles a synchronous
handler followed by one dirty pass. Advance application timers or release
application-owned gates explicitly when an action schedules additional work.
Queued output wakes the Request automatically; `synctest.Wait` supplies
synchronization. Fake time then crosses the normal dirty-update interval without
a real-time delay. This settles a synchronous handler followed by one dirty pass.
Advance application timers or release application-owned gates explicitly when an
action schedules additional work.

After the final wait, compare the complete collected records with the expected
commands and targets, including the absence of unrelated targets. Count
Expand Down
3 changes: 3 additions & 0 deletions doc/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ These are logical payloads before wire quoting:
| `SClass`, `RClass` | One class name |
| `Value` | Live control value, not an HTML attribute value |

`Reload` is the final outbound record. The writer sends its batch and closes the
WebSocket, discarding any later records.

Browser `Input` and `JsVar` events invoke `JawsInput` on their Element. `Input`
carries the control value. `JsVar` carries a `path=json` proposal. Click and
context-menu events carry coordinates, modifier state, the nearest name, and
Expand Down
80 changes: 7 additions & 73 deletions element.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import (
// or updates, though its fields are left intact and its methods still operate on the
// now finished Request. Request identities are never reused, so a retained Element
// can never come to represent an unrelated connection.
//
// Browser commands queued by Element methods wake a running Request's processing loop.
// Event handlers run concurrently with updates; use [Request.Dirty] for changes
// that need to be applied through [Updater.JawsUpdate].
type Element struct {
*Request // (read-only) the Request the Element belongs to
// internals
Expand Down Expand Up @@ -220,16 +224,6 @@ func (elem *Element) JsVar(data string) {
// queue enqueues a wire message of the given type and data for this element on
// its Request, tagged with the element's Jid. It is a no-op once the element has
// been deleted.
//
// It is intended to be called while the element is rendering or updating; the
// message is appended to the Request's muQueue-guarded outbound queue and flushed
// the next time the processing loop runs a send pass. During rendering and updating
// that pass is imminent. Called from an event handler, however, the message is
// flushed only when the loop is next woken — by a broadcast, an incoming event, or a
// dirty-driven update — which on an otherwise-idle request is not guaranteed to
// happen promptly. The reliable event-driven path is therefore to mark the element
// dirty (see [Request.Dirty]), which schedules a [Updater.JawsUpdate] and the wakeup
// that delivers it.
func (elem *Element) queue(wht what.What, data string) (queued bool) {
if !elem.deleted.Load() {
elem.Request.queue(wire.WsMsg{
Expand Down Expand Up @@ -258,12 +252,6 @@ func isReservedAttr(attr string) bool {
// The framework-owned "id" attribute is rejected (ASCII case-insensitively):
// attempting to set it is reported as [ErrReservedAttribute] via reportMisuse and
// nothing is sent, since it carries the [Element]'s JaWS identity.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) SetAttr(attr, value string) {
if isReservedAttr(attr) {
elem.Jaws.reportMisuse(fmt.Errorf("jaws: Element.SetAttr: %q: %w", attr, ErrReservedAttribute))
Expand All @@ -278,12 +266,6 @@ func (elem *Element) SetAttr(attr, value string) {
// The framework-owned "id" attribute is rejected (ASCII case-insensitively):
// attempting to remove it is reported as [ErrReservedAttribute] via reportMisuse and
// nothing is sent, since it carries the [Element]'s JaWS identity.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) RemoveAttr(attr string) {
if isReservedAttr(attr) {
elem.Jaws.reportMisuse(fmt.Errorf("jaws: Element.RemoveAttr: %q: %w", attr, ErrReservedAttribute))
Expand All @@ -294,24 +276,12 @@ func (elem *Element) RemoveAttr(attr string) {

// SetClass queues sending a class
// to the browser for the [Element].
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) SetClass(cls string) {
elem.queue(what.SClass, cls)
}

// RemoveClass queues sending a request to remove a class
// to the browser for the [Element].
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) RemoveClass(cls string) {
elem.queue(what.RClass, cls)
}
Expand All @@ -321,25 +291,17 @@ func (elem *Element) RemoveClass(cls string) {
// When innerHTML exactly matches the browser's current serialized inner HTML,
// JaWS leaves the existing descendants and their live state unchanged. Use
// [Element.Replace] when matching markup must still create new nodes.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt.
func (elem *Element) SetInner(innerHTML template.HTML) {
elem.queue(what.Inner, string(innerHTML))
}

// SetValue queues sending a new current input value in textual form
// to the browser for the [Element].
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To reconcile only this Element after a browser event, call
// To reconcile only this Element after a browser event, call
// elem.Dirty(elem); this schedules JawsUpdate on the Request loop and serializes the
// correction with other updates. Dirty a source tag instead when shared application
// state changed. Calling SetValue directly from an event handler may not flush
// promptly and bypasses that update ordering.
// state changed. Calling SetValue directly bypasses that update ordering.
func (elem *Element) SetValue(value string) {
elem.queue(what.Value, value)
}
Expand All @@ -351,10 +313,7 @@ func (elem *Element) SetValue(value string) {
// jsfunc must be an application-controlled dot path; put user data in jsonstr,
// not jsfunc.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent; a call queued directly from an event handler is only flushed when the
// processing loop is next woken. To call JavaScript for every element matching a
// tag, use [Jaws.JsCall].
// To call JavaScript for every element matching a tag, use [Jaws.JsCall].
func (elem *Element) JsCall(jsfunc, jsonstr string) {
elem.queue(what.Call, jsCallData(jsfunc, jsonstr))
}
Expand All @@ -378,12 +337,6 @@ func (elem *Element) JsCall(jsfunc, jsonstr string) {
// expected id attribute. If the guard does not find it, the call is a programming
// error: debug builds panic and production builds report it via [Jaws.MustLog]
// and skip the replacement.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt.
func (elem *Element) Replace(htmlCode template.HTML) {
if !elem.deleted.Load() {
var b []byte
Expand All @@ -400,12 +353,6 @@ func (elem *Element) Replace(htmlCode template.HTML) {
}

// Append appends a new HTML element as a child to the current one.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) Append(htmlCode template.HTML) {
elem.queue(what.Append, string(htmlCode))
}
Expand All @@ -417,7 +364,6 @@ func (elem *Element) Append(htmlCode template.HTML) {
// command is queued. The browser also verifies that child is a direct DOM child
// of elem before applying the insertion.
//
// Call this while elem is rendering or updating, when a send pass is imminent.
// To insert HTML at the same child index in every element matching a tag, use
// [Jaws.Insert].
func (elem *Element) InsertBefore(child *Element, htmlCode template.HTML) {
Expand All @@ -427,12 +373,6 @@ func (elem *Element) InsertBefore(child *Element, htmlCode template.HTML) {
}

// Order reorders the HTML elements.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) Order(jidList []jid.Jid) {
if !elem.deleted.Load() && len(jidList) > 0 {
var b []byte
Expand All @@ -453,12 +393,6 @@ func (elem *Element) Order(jidList []jid.Jid) {
// nor the registry is changed. The caller is responsible for ensuring child is
// a direct DOM child of elem; the browser verifies that relationship before
// applying the removal.
//
// Call this while the [Element] is rendering or updating, when a send pass is
// imminent. To change the [Element] in response to a browser event, mark it dirty
// with [Request.Dirty] instead: a change queued directly from an event handler is
// flushed only when the processing loop is next woken, which on an otherwise-idle
// request is not guaranteed to be prompt (see [Element.queue]).
func (elem *Element) Remove(child *Element) {
if elem.validChildElement("Remove", child) {
if elem.queue(what.Remove, child.Jid().String()) {
Expand Down
11 changes: 2 additions & 9 deletions element_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,9 @@ func TestElement_Queued(t *testing.T) {
th.NoErr(rq.UI(tss))
rq.Jaws.Dirty(tss)
rq.Dirty(tss)
// The Serve loop only broadcasts what.Update when its updateTicker fires
// The Serve loop only distributes dirt when its updateTicker fires
// (1ms in tests). Advance the fake clock past it, then let the process
// loop drain the update and invoke JawsUpdate.
// loop drain its dirty work and invoke JawsUpdate.
time.Sleep(2 * time.Millisecond)
synctest.Wait()
n := atomic.LoadInt32(&tss.updateCalled)
Expand Down Expand Up @@ -558,13 +558,6 @@ func TestElement_ReplaceMessageTargetsElementHTML(t *testing.T) {
html := `<div id="` + jid.String() + `">replaced</div>`

elem.Replace(template.HTML(html))
// Element.Replace queues directly on the Request, so poke the process loop
// once to ensure queued messages are flushed to OutCh in this harness.
select {
case rq.InCh <- wire.WsMsg{}:
case <-time.After(time.Second):
t.Fatal("timeout waking request process loop")
}
msg := nextOutboundMsg(t, rq)

if msg.What != what.Replace {
Expand Down
1 change: 0 additions & 1 deletion examples/minesweeper/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,6 @@ func TestLargeFloodUpdatesBothRequests(t *testing.T) {
if err := tmpl.ExecuteTemplate(&body, "index.html", ui.With{RequestWriter: rw, Dot: g}); err != nil {
t.Fatal(err)
}
c.request.BcastCh <- wire.Message{What: what.Update}
clients = append(clients, c)
}
settle := func() {
Expand Down
1 change: 1 addition & 0 deletions lib/assets/jaws.js
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ function jawsPerform(what, id, data) {
data = JSON.parse(data);
switch (what) {
case 'Reload':
jawsUnloading();
window.location.reload();
return;
case 'Redirect':
Expand Down
26 changes: 26 additions & 0 deletions lib/assets/js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,32 @@ process.stdout.write(JSON.stringify({reloads: reloads, errors: errors, state: ja
}
}

func TestJawsJS_ReloadStopsReconnect(t *testing.T) {
raw := runJawsJSSnippet(t, `
let reloads = 0, closes = 0, timers = 0;
window.location.reload = function() { reloads++; };
setTimeout = function() { timers++; };
function FakeSocket() {}
FakeSocket.prototype.removeEventListener = function() {};
FakeSocket.prototype.close = function() { closes++; };
WebSocket = FakeSocket;
jaws = new FakeSocket();
jawsMessage({data: 'Reload\t\t""\n'});
jawsFailed();
process.stdout.write(JSON.stringify({reloads, closes, timers, stopped: jaws === null}));
`)
var got struct {
Reloads, Closes, Timers int
Stopped bool
}
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatal(err)
}
if got.Reloads != 1 || got.Closes != 1 || got.Timers != 0 || !got.Stopped {
t.Fatalf("reload handling = %+v", got)
}
}

func TestJawsJS_StorePathGuards(t *testing.T) {
raw := runJawsJSSnippet(t, `
function FakeSocket() { this.readyState = 1; this.sent = []; }
Expand Down
6 changes: 0 additions & 6 deletions lib/ui/container_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,6 @@ func TestContainer_UpdateEmitsWireOps(t *testing.T) {

tc.contents = []jaws.UI{span1, span2}
elem.JawsUpdate()
// Wake the harness loop so the queued ops flush to OutCh.
tr.InCh <- wire.WsMsg{}

synctest.Wait()
var sawAppend, sawOrder bool
Expand Down Expand Up @@ -409,7 +407,6 @@ func TestContainerUpdateRenderErrorDoesNotAppendFailedChild(t *testing.T) {
t.Fatalf("failed append child %v leaked into the request registry", failingChild.jid)
}

tr.InCh <- wire.WsMsg{}
synctest.Wait()
for {
select {
Expand Down Expand Up @@ -736,7 +733,6 @@ func TestSelectWidget_AppliesGetterAfterInitialRender(t *testing.T) {
t.Fatalf("initial option markup unexpectedly selected an option: %s", got)
}

tr.InCh <- wire.WsMsg{}
select {
case msg := <-tr.OutCh:
if msg.What != what.Value || msg.Data != tc.want {
Expand Down Expand Up @@ -791,7 +787,6 @@ func TestSelectWidget_AppendsOptionBeforeSettingNewValue(t *testing.T) {
if err := elem.JawsRender(&sb, nil); err != nil {
t.Fatal(err)
}
tr.InCh <- wire.WsMsg{}
select {
case msg := <-tr.OutCh:
if msg.What != what.Value || msg.Data != "1" {
Expand All @@ -804,7 +799,6 @@ func TestSelectWidget_AppendsOptionBeforeSettingNewValue(t *testing.T) {
sh.Set("2")
sh.contents = []jaws.UI{opt1, opt2}
selectUI.JawsUpdate(elem)
tr.InCh <- wire.WsMsg{}

sawAppend := false
for {
Expand Down
3 changes: 0 additions & 3 deletions lib/ui/input_widgets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,23 +126,20 @@ func TestInputBool_JawsUpdateEmitsCheckedState(t *testing.T) {
// false -> true emits "true".
sb.Set(true)
checkbox.JawsUpdate(elem)
tr.InCh <- wire.WsMsg{} // wake the loop so the queued op flushes to OutCh
if v, ok := waitValue(); !ok || v != "true" {
t.Fatalf("expected SetValue %q on false->true, got ok=%v v=%q", "true", ok, v)
}

// true -> false emits "false".
sb.Set(false)
checkbox.JawsUpdate(elem)
tr.InCh <- wire.WsMsg{}
if v, ok := waitValue(); !ok || v != "false" {
t.Fatalf("expected SetValue %q on true->false, got ok=%v v=%q", "false", ok, v)
}

// Unchanged value emits nothing (Last.Swap dedup).
checkbox.JawsUpdate(elem)
checkbox.JawsUpdate(elem)
tr.InCh <- wire.WsMsg{}
if v, ok := waitValue(); ok {
t.Fatalf("unchanged bool value re-emitted SetValue %q", v)
}
Expand Down
Loading
Loading