bigskysoftware / bigskysoftware/htmx

[4.0.0] hx-sync="replace" lets an older response overwrite the latest result

Open
#4,027 0 comments 1 reaction 0 assignees View on GitHub
Dominant language
JavaScript
Stars
49.4k
Forks
1.7k
Avg merge
3d 22h
Merged PRs (30d)
30

Description

With `hx-sync="#result:replace"`, an older response can overwrite the latest result.

Three requests, A, B, and C, start in that order:

1. B cancels A.
2. A finishes its cleanup.
3. C starts, but does not cancel B.
4. C updates the page.
5. B responds later and overwrites C.

I expected C to cancel B and remain the final result.

## Reproduction

The files are `main.go` and `repro.html` below, plus `htmx.js` from the official htmx v4.0.0 release.

Run `go run main.go` and open . The test starts automatically.

A and B respond after 1.5 seconds. C responds after 50 milliseconds.
The page starts C in the next task after A's final request event, so A's cleanup finishes first.
The final result is **B**, but it should be **C**.

main.go

```go
package main

import (
"fmt"
"log"
"net/http"
"time"
)

func main() {
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
http.HandleFunc("/reply", func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
log.Printf("received %s %s", r.Method, r.RequestURI)
name := r.URL.Query().Get("name")
delay := 1500 * time.Millisecond
if name == "C" {
delay = 50 * time.Millisecond
}
select {
case <-r.Context().Done():
log.Printf("cancelled %s after %s", r.RequestURI, time.Since(started))
return
case <-time.After(delay):
}
w.Header().Set("Content-Type", "text/html")
_, err := fmt.Fprint(w, name)
log.Printf("response written %s after %s; error=%v", r.RequestURI, time.Since(started), err)
})
http.Handle("/", http.FileServer(http.Dir(".")))
log.Print("Open http://127.0.0.1:8765/repro.html")
log.Fatal(http.ListenAndServe("127.0.0.1:8765", nil))
}
```

repro.html

```html

htmx replace queue race

htmx replace queue race


Expected final content: C. Bug: B overwrites C. Reload to repeat.


A
B
C
Waiting

const log = message => document.querySelector('#log').textContent += message + '\n';
const aborted = [];
const swaps = [];
const finished = new Set();
document.addEventListener('htmx:before:request', event => {
const {ctx} = event.detail;
const name = ctx.sourceElement.id;
log('start ' + name);
ctx.request.signal.addEventListener('abort', () => {
aborted.push(name);
log('abort ' + name);
});
if (name === 'A') setTimeout(() => document.querySelector('#B').click(), 100);
});
document.addEventListener('htmx:after:swap', () => {
swaps.push(document.querySelector('#result').textContent.trim());
log('render ' + swaps.at(-1));
});
document.addEventListener('htmx:finally:request', event => {
const name = event.detail.ctx.sourceElement.id;
log('finally ' + name);
finished.add(name);
// Next task: A's entire finally block, including queue cleanup, has returned.
if (name === 'A') setTimeout(() => document.querySelector('#C').click(), 0);
if (finished.has('B') && finished.has('C')) setTimeout(() => {
const bug = !aborted.includes('B') && swaps.join(',') === 'C,B';
log(bug ? 'BUG REPRODUCED: B was not aborted; B overwrote C' : 'UNEXPECTED RESULT');
document.body.dataset.done = 'true';
}, 0);
});
window.addEventListener('load', () => document.querySelector('#A').click());

```

## Cause

`RequestQueue.admit()` cancels A and makes B the active request.
Then A's `finally` block calls `requestQueue.continue()`, which clears the active request without checking which request called it.
C sees an empty queue slot, so it does not cancel B. B can then overwrite C's result.

This also affects [`four-dev`](https://github.com/bigskysoftware/htmx/tree/four-dev); I checked commit [`4a7a447`](https://github.com/bigskysoftware/htmx/commit/4a7a4478e7b7a7527bd4dabbc2590d4af576c090).

Contributor guide

Open the contributing guide

Research direction

Start with the RequestQueue.admit() and requestQueue.continue() paths in htmx.js, using repro.html and main.go to reproduce the A/B/C timing. Run `go run main.go`, open the documented URL, and confirm the current C,B result. Done means the reproduction no longer reports the bug and C remains the final result.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, javascript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.