Skip to content

Commit a6b8e08

Browse files
authored
Merge pull request #13 from cardmagic/agent/fix-batch-revision-races
fix: coordinate batch refreshes by revision
2 parents c1f12ed + e4304e1 commit a6b8e08

6 files changed

Lines changed: 290 additions & 122 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.7.3 - 2026-08-09
4+
5+
- Coordinate batched component refreshes by revision as well as scope and batch
6+
name. Invalidations for one revision arrive as separate WebSocket messages, so
7+
the microtask merge could not see them all, and each request aborted the one
8+
before it. Only the last component updated. Same-revision requests now run
9+
alongside each other and every frame is applied; only a strictly newer
10+
revision supersedes an in-flight request. Frames already applied at a revision
11+
are not applied twice.
12+
313
## 0.7.2 - 2026-08-09
414

515
- Render batched component partials as HTML regardless of the request format.

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.7.2)
4+
solid_objects (0.7.3)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -373,7 +373,7 @@ CHECKSUMS
373373
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
374374
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
375375
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
376-
solid_objects (0.7.2)
376+
solid_objects (0.7.3)
377377
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
378378
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
379379
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

app/assets/javascripts/solid_objects/component_batch_refresh.js

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const pendingBatches = new Map()
22
const activeBatches = new Map()
3+
const appliedRevisions = new Map()
4+
let requestSequence = 0
35

46
class SolidObjectsBatchRefreshElement extends HTMLElement {
57
connectedCallback() {
@@ -34,18 +36,26 @@ class SolidObjectsBatchRefreshElement extends HTMLElement {
3436
pendingBatches.set(key, merged)
3537
queueMicrotask(() => {
3638
pendingBatches.delete(key)
37-
requestBatch(group, batch, merged.sources)
39+
requestBatch(group, batch, revision, merged.sources)
3840
})
3941
this.remove()
4042
}
4143
}
4244

43-
async function requestBatch(group, batch, sources) {
44-
const previous = activeBatches.get(group)
45-
previous?.abort()
46-
45+
// Invalidations for one revision arrive in separate WebSocket messages, so the
46+
// microtask merge cannot see them all. Requests are tracked per revision and a
47+
// request is only cancelled by a strictly newer one; same-revision requests run
48+
// alongside each other and every frame is applied.
49+
async function requestBatch(group, batch, revision, sources) {
50+
const parsed = parseRevision(revision)
51+
supersedeOlderRequests(group, parsed)
52+
53+
// Same-revision requests run concurrently, so each needs its own entry.
54+
// Sharing one key per revision would leave all but the last untracked and
55+
// therefore impossible to supersede.
56+
const key = `${group}:${revision}:${(requestSequence += 1)}`
4757
const controller = new AbortController()
48-
activeBatches.set(group, controller)
58+
activeBatches.set(key, { controller, group, revision: parsed })
4959

5060
try {
5161
const url = mergedUrl(sources)
@@ -68,10 +78,29 @@ async function requestBatch(group, batch, sources) {
6878
} catch (error) {
6979
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
7080
} finally {
71-
if (activeBatches.get(group) === controller) activeBatches.delete(group)
81+
if (activeBatches.get(key)?.controller === controller) activeBatches.delete(key)
7282
}
7383
}
7484

85+
function supersedeOlderRequests(group, revision) {
86+
if (!revision) return
87+
88+
activeBatches.forEach((entry, key) => {
89+
if (entry.group !== group) return
90+
if (!olderRevision(entry.revision, revision)) return
91+
92+
entry.controller.abort()
93+
activeBatches.delete(key)
94+
})
95+
}
96+
97+
function olderRevision(candidate, current) {
98+
if (!candidate || !current) return false
99+
100+
return candidate[0] < current[0] ||
101+
(candidate[0] === current[0] && candidate[1] < current[1])
102+
}
103+
75104
// Every notification for one batch and revision carries the same endpoint and
76105
// differs only by which components changed, so the union of their tokens is the
77106
// complete set to render.
@@ -93,6 +122,13 @@ function applyFrame(frame) {
93122
const target = document.getElementById(frame?.target)
94123
if (!target || !frame.html) return
95124
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
125+
// Concurrent same-revision responses can carry the same frame. The target's
126+
// own revision only advances once Turbo applies the stream, so what has
127+
// already been applied is tracked here as well.
128+
const applied = appliedRevisions.get(frame.target)
129+
if (applied && !newerRevision(frame.revision, applied)) return
130+
131+
appliedRevisions.set(frame.target, frame.revision)
96132

97133
const parsed = new DOMParser().parseFromString(frame.html, "text/html")
98134
const replacement = parsed.getElementById(frame.target)

docs/realtime.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,11 @@ they are while giving the client a documented contract with per-frame revisions.
173173
### What the protocol guarantees
174174

175175
Only components whose dependencies changed are requested; the rest are never
176-
named in the batch. Duplicate notifications for the same batch and revision merge
177-
into one request, and a superseded request for the same batch is aborted. Each
176+
named in the batch. Notifications for the same batch and revision that arrive in
177+
one task merge into a single request. Notifications that arrive in separate
178+
WebSocket messages issue their own requests and all of their frames are applied,
179+
because cancelling a same-revision request would drop the components it carried.
180+
Only a strictly newer revision supersedes an in-flight request. Each
178181
frame carries its own revision and cannot overwrite a target that already holds a
179182
newer one. Authorization is unchanged: every component in the batch passes the
180183
same `authorize_query` boundary an individual refresh uses, and the batch name is

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.7.2"
4+
VERSION = "0.7.3"
55
end

0 commit comments

Comments
 (0)