hibiki_rails 0.20.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e04ed317fc77afc03ef9dcb0ec2fc1f52833e23dd0700934e3bee3551c0f1549
4
- data.tar.gz: b76c0323f43becb86476653aebd6cd58e01e1ab917c18fdf5e7e843c95f01e60
3
+ metadata.gz: fcb75b30dd2f92f65a4581f6b26ca93f92ef7665c333139b3b6b1f839db70943
4
+ data.tar.gz: 4de446211377d8a95301b0fc5cd9d5361d6ce76489da9bb224711f90a29d51a2
5
5
  SHA512:
6
- metadata.gz: c6b3b937fcaeba352b86432ceef1b4ec526e26e74509b83f39a868db656706fd4f89c64d6cd3d3805a6c5553376a1f09765dbd939ec28156bf3bb8cbcb696e45
7
- data.tar.gz: 889c7cea1cfc1c772e6a18573e2717fbf8a569ff679a5a4f6208d2b961db6d80c4b7d8c00435e4be8783a7a57adfb22bb162d7d1624b566fedfd4f3ebbaeaf88
6
+ metadata.gz: b4c546401703167cc3d2f6ed5690a0b93d95f631fd311d3cc1883a51a6b29365a9a631251690fc351708fba80af4e73fd303034c92f5f7192c6148da26aea644
7
+ data.tar.gz: 3657421c216fcc9f44c2a7d6d7a14a44e0737d9029e37f4e2a0652c31b4b486aba25f4888c045df32d2c88ba1f55f0de4b18cdaf43df29dd68b1fb89cb8b2887
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.20.1 — 2026-10-12
8
+
9
+ ### Fixed
10
+
11
+ **A channel runs its actions in the order the browser sent them.** Action
12
+ Cable hands each incoming message to a worker pool, so two actions sent back
13
+ to back could run swapped. The client sends such a pair whenever a visitor
14
+ types in a debounced field and then clicks or presses Enter at once: the
15
+ typed value and the action leave in the same instant. When they swapped, the
16
+ action ran before the value arrived, so a save could miss the last thing
17
+ typed or be refused.
18
+
19
+ - The channel now runs each subscription's actions by the number the client
20
+ gives them. An action that arrives ahead of its turn waits for the ones
21
+ before it, for at most 100 ms.
22
+ - Update the gem and the npm package together. A client older than 0.20.1
23
+ still works with the new gem, but its first action after a reconnect waits
24
+ 100 ms.
25
+ - Nothing changes in your channels or views.
26
+ - An unsubscribe is not numbered, and can still overtake an action sent just
27
+ before the page is left.
28
+
7
29
  ## 0.20.0 — 2026-10-12
8
30
 
9
31
  ### Added
@@ -422,10 +422,19 @@ export class ChannelController extends Controller {
422
422
  // undefined when the payload went nowhere — the socket turned out to
423
423
  // be closed, or the island was already offline — and the caller owns
424
424
  // recovery.
425
+ //
426
+ // The server runs a subscription's actions in seq order (Action Cable's
427
+ // worker pool can swap two sent back to back), so the numbers it sees
428
+ // must run from 1 with none missing: an action dropped in the offline gap
429
+ // takes no number, and linkClosed starts the count again for the fresh
430
+ // subscription a reconnect makes.
425
431
  perform(action, payload = {}) {
432
+ delete payload.hbt
433
+ // The offline gap: dropped, and the return says so — a truthy seq here
434
+ // would tell a public caller a repaint is coming when none is.
435
+ if (!this.subscribed && this.connectedOnce) return undefined
426
436
  const seq = ++this.seq
427
437
  payload.hbk = seq
428
- delete payload.hbt
429
438
  if (this.subscribed) {
430
439
  this.sendTrips(payload)
431
440
  this.beginBusy(seq, action)
@@ -453,15 +462,10 @@ export class ChannelController extends Controller {
453
462
  // the socket dropped, and the island is stamped `offline` for the
454
463
  // whole gap — the signal the connect window cannot give, where the
455
464
  // page is painted and looks live.
456
- if (!this.connectedOnce) {
457
- this.sendTrips(payload)
458
- this.beginBusy(seq, action)
459
- this.queued.push([action, payload])
460
- return seq
461
- }
462
- // The offline gap: dropped, and the return says so — a truthy seq here
463
- // would tell a public caller a repaint is coming when none is.
464
- return undefined
465
+ this.sendTrips(payload)
466
+ this.beginBusy(seq, action)
467
+ this.queued.push([action, payload])
468
+ return seq
465
469
  }
466
470
 
467
471
  // ActionCable's `connected`, i.e. the server confirmed the subscription.
@@ -484,6 +488,8 @@ export class ChannelController extends Controller {
484
488
  // reason — their acks are never coming.
485
489
  this.queued = []
486
490
  this.settleAll()
491
+ // The fresh subscription expects its actions numbered from 1.
492
+ this.seq = 0
487
493
  }
488
494
 
489
495
  setState(state) {
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/scheduled_task"
4
+
5
+ module Hibiki
6
+ module Rails
7
+ # Puts one subscription's incoming actions back in the order the client
8
+ # sent them. ActionCable hands every websocket message to a worker pool
9
+ # (Connection#receive is send_async), so two actions sent back to back
10
+ # can reach Channel#perform_action in either order — and the client's
11
+ # flush sends a typed field and the click after it in one tick.
12
+ #
13
+ # The client numbers its actions under `hbk`, from 1 per subscription
14
+ # with no gaps. #submit calls each job when its number comes up: one
15
+ # ahead of its turn is held until the missing ones arrive, or until
16
+ # GRACE runs out for that gap (a client older than this numbers on
17
+ # across a reconnect, so its first action there waits one grace). A
18
+ # number whose turn has passed, a repeat, and anything that is not an
19
+ # Integer run at once.
20
+ #
21
+ # Jobs run under the lock, on whichever thread releases them, so they
22
+ # must only enqueue: the order of the calls is the order of the queue.
23
+ # Like Registry, this is glue touched from many cable threads, not the
24
+ # graph; a lock is the right tool here.
25
+ class ActionOrder
26
+ # Seconds one gap is waited for. A websocket does not lose messages,
27
+ # so only a number that was never sent waits it out.
28
+ GRACE = 0.1
29
+
30
+ def initialize
31
+ @lock = Mutex.new
32
+ @due = 1
33
+ @held = {}
34
+ @closed = false
35
+ end
36
+
37
+ def submit(seq, &job)
38
+ return job.call unless seq.is_a?(Integer)
39
+
40
+ @lock.synchronize do
41
+ next job.call if @closed || seq < @due || @held.key?(seq)
42
+
43
+ @held[seq] = job
44
+ release
45
+ end
46
+ end
47
+
48
+ # Run what is held, in order, and let everything after straight
49
+ # through. Channel#unsubscribed calls it ahead of the dispose.
50
+ def close
51
+ @lock.synchronize do
52
+ @closed = true
53
+ disarm
54
+ @held.sort.each { |_, job| job.call }
55
+ @held.clear
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def release
62
+ while (job = @held.delete(@due))
63
+ @due += 1
64
+ job.call
65
+ end
66
+ watch
67
+ end
68
+
69
+ # One timer per gap: more arrivals behind the same gap do not restart
70
+ # it. `@waiting` is the number waited for, which is also what tells a
71
+ # timer that fires late it is stale.
72
+ def watch
73
+ return disarm if @held.empty?
74
+ return if @waiting == @due
75
+
76
+ disarm
77
+ waiting = @waiting = @due
78
+ @timer = Concurrent::ScheduledTask.execute(GRACE) { give_up(waiting) }
79
+ end
80
+
81
+ def disarm
82
+ @timer&.cancel
83
+ @timer = @waiting = nil
84
+ end
85
+
86
+ def give_up(waiting)
87
+ @lock.synchronize do
88
+ next unless @waiting == waiting
89
+
90
+ @due = @held.keys.min
91
+ release
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -93,14 +93,17 @@ module Hibiki
93
93
  # `hbt` is the third reserved key: the trips the browser timed since
94
94
  # its last action (see Instrumentation#trips_from). Deleted whether or
95
95
  # not anyone listens, for the same reason as `hbk`.
96
+ #
97
+ # ActionCable runs this on its worker pool, several at once and in no
98
+ # order, so the post waits for the seq's turn (see ActionOrder). An
99
+ # action with no seq takes no turn. `asked` is read on arrival: a held
100
+ # action's wait is part of its `action` event.
96
101
  def perform_action(data)
97
102
  seq = data.delete("hbk")
98
103
  trips = trips_from(data.delete("hbt"))
99
104
  asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
100
105
 
101
- # A nil actor means the subscription was rejected or already torn
102
- # down; drop the action rather than blow up the cable thread.
103
- posted = @__hibiki_actor&.post do
106
+ post_in_turn(seq) do
104
107
  trips.each { instrument("trip", it) }
105
108
  instrument_action(data, asked) { Hibiki.batch { super(data) } }
106
109
  ensure
@@ -111,13 +114,6 @@ module Hibiki
111
114
  # in exactly the case the user most needs it to stop.
112
115
  transmit({ ack: seq }) if seq
113
116
  end
114
-
115
- # post yields nil (no actor) or false (queue closed): the block
116
- # never ran, so neither did its ensure. `dropped` is also what lets
117
- # the client tell "late" from "never" — nothing is coming for this
118
- # seq, so it settles immediately rather than waiting out the grace
119
- # window.
120
- transmit({ ack: seq, dropped: true }) if seq && !posted
121
117
  end
122
118
 
123
119
  private
@@ -126,6 +122,7 @@ module Hibiki
126
122
  super
127
123
  return reject if cid.blank?
128
124
 
125
+ @__hibiki_order = ActionOrder.new
129
126
  @__hibiki_actor = build_graph_actor
130
127
  # The block runs owned + untracked (Hibiki.root); the effects it
131
128
  # creates do their dependency-collecting first run right here, on
@@ -144,10 +141,32 @@ module Hibiki
144
141
  return unless @__hibiki_actor
145
142
 
146
143
  Hibiki::Rails.registry.unregister(self)
144
+ # Held actions run first: the dispose must be the last job posted.
145
+ @__hibiki_order.close
147
146
  @__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
148
147
  @__hibiki_actor.stop
149
148
  end
150
149
 
150
+ # Post the job to the graph when the action numbered `seq` is due. A
151
+ # rejected subscription has no order and nothing to keep in order.
152
+ #
153
+ # A nil actor means the subscription was rejected or already torn
154
+ # down; drop the action rather than blow up the cable thread. post
155
+ # yields nil (no actor) or false (queue closed): the job never ran, so
156
+ # neither did its ensure. `dropped` is also what lets the client tell
157
+ # "late" from "never" — nothing is coming for this seq, so it settles
158
+ # immediately rather than waiting out the grace window.
159
+ def post_in_turn(seq, &job)
160
+ return post_or_drop(seq, job) unless @__hibiki_order
161
+
162
+ @__hibiki_order.submit(seq) { post_or_drop(seq, job) }
163
+ end
164
+
165
+ def post_or_drop(seq, job)
166
+ posted = @__hibiki_actor&.post(&job)
167
+ transmit({ ack: seq, dropped: true }) if seq && !posted
168
+ end
169
+
151
170
  # Build the signal graph: create states/deriveds/effects as instance
152
171
  # variables so actions can reach them. Runs once, on the graph's
153
172
  # thread, inside Hibiki.root.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.20.0"
5
+ VERSION = "0.20.1"
6
6
  end
7
7
  end
data/lib/hibiki/rails.rb CHANGED
@@ -67,6 +67,7 @@ end
67
67
  require_relative "rails/version"
68
68
  require_relative "rails/registry"
69
69
  require_relative "rails/graph_actor"
70
+ require_relative "rails/action_order"
70
71
  require_relative "rails/debounce"
71
72
  require_relative "rails/instrumentation"
72
73
  require_relative "rails/broadcasts"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.20.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -181,6 +181,7 @@ files:
181
181
  - lib/generators/hibiki/rails/upload_field_helpers.rb
182
182
  - lib/generators/hibiki/rails/upload_field_injections.rb
183
183
  - lib/hibiki/rails.rb
184
+ - lib/hibiki/rails/action_order.rb
184
185
  - lib/hibiki/rails/broadcasts.rb
185
186
  - lib/hibiki/rails/channel.rb
186
187
  - lib/hibiki/rails/commands.rb