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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## 0.7.3 - 2026-08-09

- Coordinate batched component refreshes by revision as well as scope and batch
name. Invalidations for one revision arrive as separate WebSocket messages, so
the microtask merge could not see them all, and each request aborted the one
before it. Only the last component updated. Same-revision requests now run
alongside each other and every frame is applied; only a strictly newer
revision supersedes an in-flight request. Frames already applied at a revision
are not applied twice.

## 0.7.2 - 2026-08-09

- Render batched component partials as HTML regardless of the request format.
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.7.2)
solid_objects (0.7.3)
actioncable (>= 8.0)
actionpack (>= 8.0)
actionview (>= 8.0)
Expand Down Expand Up @@ -373,7 +373,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.7.2)
solid_objects (0.7.3)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
50 changes: 43 additions & 7 deletions app/assets/javascripts/solid_objects/component_batch_refresh.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const pendingBatches = new Map()
const activeBatches = new Map()
const appliedRevisions = new Map()
let requestSequence = 0

class SolidObjectsBatchRefreshElement extends HTMLElement {
connectedCallback() {
Expand Down Expand Up @@ -34,18 +36,26 @@ class SolidObjectsBatchRefreshElement extends HTMLElement {
pendingBatches.set(key, merged)
queueMicrotask(() => {
pendingBatches.delete(key)
requestBatch(group, batch, merged.sources)
requestBatch(group, batch, revision, merged.sources)
})
this.remove()
}
}

async function requestBatch(group, batch, sources) {
const previous = activeBatches.get(group)
previous?.abort()

// Invalidations for one revision arrive in separate WebSocket messages, so the
// microtask merge cannot see them all. Requests are tracked per revision and a
// request is only cancelled by a strictly newer one; same-revision requests run
// alongside each other and every frame is applied.
async function requestBatch(group, batch, revision, sources) {
const parsed = parseRevision(revision)
supersedeOlderRequests(group, parsed)

// Same-revision requests run concurrently, so each needs its own entry.
// Sharing one key per revision would leave all but the last untracked and
// therefore impossible to supersede.
const key = `${group}:${revision}:${(requestSequence += 1)}`
const controller = new AbortController()
activeBatches.set(group, controller)
activeBatches.set(key, { controller, group, revision: parsed })

try {
const url = mergedUrl(sources)
Expand All @@ -68,10 +78,29 @@ async function requestBatch(group, batch, sources) {
} catch (error) {
if (error.name !== "AbortError") dispatchBatchError(batch, "request_failed")
} finally {
if (activeBatches.get(group) === controller) activeBatches.delete(group)
if (activeBatches.get(key)?.controller === controller) activeBatches.delete(key)
}
}

function supersedeOlderRequests(group, revision) {
if (!revision) return

activeBatches.forEach((entry, key) => {
if (entry.group !== group) return
if (!olderRevision(entry.revision, revision)) return

entry.controller.abort()
activeBatches.delete(key)
})
}

function olderRevision(candidate, current) {
if (!candidate || !current) return false

return candidate[0] < current[0] ||
(candidate[0] === current[0] && candidate[1] < current[1])
}

// Every notification for one batch and revision carries the same endpoint and
// differs only by which components changed, so the union of their tokens is the
// complete set to render.
Expand All @@ -93,6 +122,13 @@ function applyFrame(frame) {
const target = document.getElementById(frame?.target)
if (!target || !frame.html) return
if (!newerRevision(frame.revision, target.dataset.solidObjectsRevision)) return
// Concurrent same-revision responses can carry the same frame. The target's
// own revision only advances once Turbo applies the stream, so what has
// already been applied is tracked here as well.
const applied = appliedRevisions.get(frame.target)
if (applied && !newerRevision(frame.revision, applied)) return

appliedRevisions.set(frame.target, frame.revision)

const parsed = new DOMParser().parseFromString(frame.html, "text/html")
const replacement = parsed.getElementById(frame.target)
Expand Down
7 changes: 5 additions & 2 deletions docs/realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,11 @@ they are while giving the client a documented contract with per-frame revisions.
### What the protocol guarantees

Only components whose dependencies changed are requested; the rest are never
named in the batch. Duplicate notifications for the same batch and revision merge
into one request, and a superseded request for the same batch is aborted. Each
named in the batch. Notifications for the same batch and revision that arrive in
one task merge into a single request. Notifications that arrive in separate
WebSocket messages issue their own requests and all of their frames are applied,
because cancelling a same-revision request would drop the components it carried.
Only a strictly newer revision supersedes an in-flight request. Each
frame carries its own revision and cannot overwrite a target that already holds a
newer one. Authorization is unchanged: every component in the batch passes the
same `authorize_query` boundary an individual refresh uses, and the batch name is
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.7.2"
VERSION = "0.7.3"
end
Loading
Loading