hibiki_rails 0.17.0 → 0.17.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: fbcb87c658d3e83cc6c10f216a69e78266878aa8396ed9662e3f06d8c6213027
4
- data.tar.gz: d6a66d149293d26e43edf4cd31ec20eb5886cc0d3db2b935b5b0c3c333965292
3
+ metadata.gz: 4919aaf1d8c96e1436138dfc5c0050602e0590447ccf9daebb5598072bf01e2d
4
+ data.tar.gz: 214048d87eec87fa482744750f736eb3d3004381864e69c12b1664a10ffaf9a5
5
5
  SHA512:
6
- metadata.gz: 0e027bd60c62abdf4d269b94cd38f0e008889b42d205b9f2d98d737f1455acd60f467cf9951a5a35f77bbc3ee02120b8e5f6397fc43dc9b7dc7842d91c73372e
7
- data.tar.gz: 28a6058a2a235ef23e02d52b5dbdf7177f97b6ba96971d9c72ea0d0302d6eaf380854b2e283069177b2038a06a2d45af540bf3b929a62f53baa7144589c33bc3
6
+ metadata.gz: f1da64047eb007f11f45f55007519808ad717f9b53968273455586c3977486f1e03e2478f9041c05a7f9d889191d6591a7e116e21d918dfb7719b337e0eeed3b
7
+ data.tar.gz: 1ed922620989a1997ef4231a7c2e737cbca070ff3c3917966c74b1ed48c28e087e08fd7763152eeb96b713e8675b5126d831b07e5e4dc10e22c31d20cb908fdb
data/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ 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.17.1 — 2026-10-09
8
+
9
+ ### Fixed
10
+
11
+ **Renders sent over the broadcast transport could reach the page out of
12
+ order.** Action Cable hands each broadcast to a pool of threads, so two
13
+ renders a channel sent a millisecond apart could be delivered in either
14
+ order. When both drew the same target, the page ended on the older one and
15
+ stayed wrong until something rendered that target again: a Save button left
16
+ enabled, a note that contradicted the server's state. It was always
17
+ possible; since 0.17.0 sends a debounced field's text just ahead of a click
18
+ or a submit, any form with one did it routinely.
19
+
20
+ - `broadcast_replace` and `broadcast_morph` now number their renders, and
21
+ the client applies them in the order the channel sent them. Nothing changes
22
+ in the channel or the view.
23
+ - A render that arrives ahead of an earlier one waits for it, for at most
24
+ 100 ms. One that arrives later than that is still drawn, unless a newer
25
+ render has drawn the same target since.
26
+ - `broadcast_refresh`, the transmit transport, and streams an app sends with
27
+ `Turbo::StreamsChannel` itself are not affected and not changed.
28
+ - Both halves are needed, so update the gem and the npm package together. A
29
+ page on one side only behaves as 0.17.0 did.
30
+ - `broadcast_replace` and `broadcast_morph` also accept `attributes:`, which
31
+ are kept on the stream beside the gem's own.
32
+
7
33
  ## 0.17.0 — 2026-10-08
8
34
 
9
35
  ### Added
@@ -66,6 +66,9 @@
66
66
  // beside them existing to change the text
67
67
  // focus sites data-hibiki-focus="true|select" takes focus, and has its
68
68
  // text selected, when a render this tab caused inserts it
69
+ // turbo streams data-hibiki-seq="3" the render's number, and
70
+ // data-hibiki-from="CounterChannel/<cid>" the island it is for;
71
+ // see "Render order" below
69
72
  //
70
73
  // The client writes the protocol's other half at runtime — read-only to
71
74
  // app code, addressed to app CSS:
@@ -118,6 +121,16 @@
118
121
  // nearest id-bearing ancestor. Only controls: when none of these is there,
119
122
  // focus is left where the browser put it. Any render, whoever caused it.
120
123
  //
124
+ // Render order. Action Cable hands each broadcast to a worker pool, so two
125
+ // renders the channel sent a millisecond apart can arrive in either order,
126
+ // and the page would end on the older one. The channel numbers its Turbo
127
+ // streams and the island applies them in that order: one that arrives ahead
128
+ // of its turn is held until the missing ones come, or until `orderGrace`
129
+ // runs out on one that never will. A render that turns up after it was
130
+ // given up on is still drawn, unless a later one has drawn the same target
131
+ // since. A stream with no number, or another island's, is left alone. The
132
+ // transmit transport needs none of this: it writes to the socket in order.
133
+ //
121
134
  // App JS reaching the graph — the ONE public seam. A gesture that needs
122
135
  // script (drag-and-drop, a third-party widget) fires its action through
123
136
  // the island's OWN subscription: `perform(action, payload)` on the island
@@ -753,6 +766,11 @@ export default class HibikiController extends ChannelController {
753
766
  // stamps no data-hibiki-params-value.
754
767
  static values = { channel: String, params: Object }
755
768
 
769
+ // ms a Turbo render that arrived ahead of its turn waits for the ones
770
+ // before it (see inOrder). Only a lost broadcast waits it out; a late one
771
+ // is a pool hop behind, a millisecond or two.
772
+ static orderGrace = 100
773
+
756
774
  // The island stamps its channel; no inference.
757
775
  channelName() {
758
776
  return this.channelValue
@@ -833,19 +851,23 @@ export default class HibikiController extends ChannelController {
833
851
  // Re-scan at the points a fragment can be swapped under us, rather
834
852
  // than blanket-observing the document: a MutationObserver over the page
835
853
  // is a real per-mutation cost paid by every app on it.
854
+ this.resetOrder()
836
855
  this.streamRender = (event) => {
837
856
  const render = event.detail.render
857
+ const stream = event.detail.newStream
858
+ // Taken now, not when the render's turn comes: see arrival().
838
859
  const arrival = this.arrival()
839
- event.detail.render = async (streamElement) => {
840
- await render(streamElement)
841
- // The Turbo transport's paint. Counting it here is what lets an ack
842
- // settle immediately instead of waiting out its grace window. The
843
- // listener is on the document, so an unrelated broadcast counts too
844
- // — which at worst settles an already-acked trip a few ms early,
845
- // never one that has not been acked at all.
846
- this.renders++
847
- this.scan(arrival)
848
- }
860
+ event.detail.render = (streamElement) =>
861
+ this.inOrder(stream, async () => {
862
+ await render(streamElement)
863
+ // The Turbo transport's paint. Counting it here is what lets an ack
864
+ // settle immediately instead of waiting out its grace window. The
865
+ // listener is on the document, so an unrelated broadcast counts too
866
+ // — which at worst settles an already-acked trip a few ms early,
867
+ // never one that has not been acked at all.
868
+ this.renders++
869
+ this.scan(arrival)
870
+ })
849
871
  }
850
872
  document.addEventListener("turbo:before-stream-render", this.streamRender)
851
873
  // A refresh stream morphs the page after its own render has returned,
@@ -882,9 +904,99 @@ export default class HibikiController extends ChannelController {
882
904
  this.unsent.clear()
883
905
  this.marks.clear()
884
906
  this.observer.disconnect()
907
+ this.resetOrder()
885
908
  super.disconnect()
886
909
  }
887
910
 
911
+ // A reconnect builds a fresh graph, which numbers its renders from 1.
912
+ linkClosed() {
913
+ this.resetOrder()
914
+ super.linkClosed()
915
+ }
916
+
917
+ // ── Render order ──────────────────────────────────────────────────────
918
+ //
919
+ // `due` is the number of the next render to apply and `held` the renders
920
+ // waiting on it, by number. `missed` and `drawn` exist only after a gap
921
+ // was given up on: the numbers skipped, and the last number drawn into
922
+ // each target since, which is what tells a late render it is stale.
923
+
924
+ // Run `job` (the render and what follows it) when the stream's turn
925
+ // comes, or never when it is stale. Jobs are CALLED in order and not
926
+ // awaited: a call reaches the swap, or the motion module's queue, before
927
+ // it returns, and that queue keeps the order from there.
928
+ inOrder(stream, job) {
929
+ const seq = Number(stream?.getAttribute?.("data-hibiki-seq"))
930
+ if (!seq || stream.getAttribute("data-hibiki-from") !== `${this.channelValue}/${this.cidValue}`) {
931
+ return job()
932
+ }
933
+ const target = stream.getAttribute("target")
934
+ const run = () => {
935
+ this.drew(target, seq)
936
+ return job()
937
+ }
938
+ if (seq < this.due) {
939
+ // Late. A number never given up on is a repeat, or older than
940
+ // anything remembered.
941
+ const stale = !this.missed.delete(seq) || this.drawn.get(target) > seq
942
+ return stale ? Promise.resolve() : run()
943
+ }
944
+ if (seq > this.due) {
945
+ return new Promise((resolve) => {
946
+ this.held.set(seq, () => resolve(run()))
947
+ this.orderTimer ??= setTimeout(() => this.giveUp(), this.constructor.orderGrace)
948
+ })
949
+ }
950
+ const done = run()
951
+ this.due++
952
+ this.release()
953
+ return done
954
+ }
955
+
956
+ // Apply the held renders that now follow on. Any still held wait on a
957
+ // new gap, with a new grace.
958
+ release() {
959
+ for (let run; (run = this.held.get(this.due)); this.due++) {
960
+ this.held.delete(this.due)
961
+ run()
962
+ }
963
+ clearTimeout(this.orderTimer)
964
+ this.orderTimer = this.held.size
965
+ ? setTimeout(() => this.giveUp(), this.constructor.orderGrace)
966
+ : undefined
967
+ }
968
+
969
+ // The grace ran out: move past the missing numbers to the first held.
970
+ giveUp() {
971
+ const first = Math.min(...this.held.keys())
972
+ for (let seq = this.due; seq < first; seq++) this.missed.add(seq)
973
+ this.missedAt = performance.now()
974
+ this.due = first
975
+ this.release()
976
+ }
977
+
978
+ // Remember what was drawn while a missed render may still turn up, and
979
+ // no longer: one later than the ceiling is lost by the same measure a
980
+ // trip is.
981
+ drew(target, seq) {
982
+ if (performance.now() - this.missedAt > this.constructor.busyCeiling) this.missed.clear()
983
+ if (!this.missed.size) return this.drawn.clear()
984
+ if (!(this.drawn.get(target) > seq)) this.drawn.set(target, seq)
985
+ }
986
+
987
+ // Start counting again from 1. Renders still held are the old graph's
988
+ // last word, so they are applied first, in order.
989
+ resetOrder() {
990
+ clearTimeout(this.orderTimer)
991
+ this.orderTimer = undefined
992
+ const held = [...(this.held ?? [])].sort(([a], [b]) => a - b)
993
+ this.due = 1
994
+ this.held = new Map()
995
+ this.missed = new Set()
996
+ this.drawn = new Map()
997
+ for (const [, run] of held) run()
998
+ }
999
+
888
1000
  // The other swap point: hibiki's own transmit transport. A held swap
889
1001
  // hands back a promise; the replacement sentinel exists only after it.
890
1002
  received(data) {
@@ -17,16 +17,16 @@ module Hibiki
17
17
 
18
18
  # Replace the element with DOM id `target` (accepts partial:/locals:,
19
19
  # html:, or anything Turbo's renderer takes).
20
- def broadcast_replace(target:, **rendering)
21
- Turbo::StreamsChannel.broadcast_replace_to(*stream_name, target:, **rendering)
20
+ def broadcast_replace(target:, attributes: {}, **rendering)
21
+ Turbo::StreamsChannel.broadcast_replace_to(
22
+ *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
23
+ )
22
24
  end
23
25
 
24
26
  # Replace via Turbo 8 morphing (<turbo-stream action="replace"
25
27
  # method="morph">): keeps focus/scroll where a plain replace resets.
26
- def broadcast_morph(target:, **rendering)
27
- Turbo::StreamsChannel.broadcast_replace_to(
28
- *stream_name, target:, attributes: { method: :morph }, **rendering
29
- )
28
+ def broadcast_morph(target:, attributes: {}, **rendering)
29
+ broadcast_replace(target:, attributes: { method: :morph }.merge(attributes), **rendering)
30
30
  end
31
31
 
32
32
  # Tell the page to refresh itself (Turbo 8 morph-everything style).
@@ -47,6 +47,17 @@ module Hibiki
47
47
  broadcast_refresh
48
48
  end
49
49
  end
50
+
51
+ # The number each render carries, and the island it is for. Action
52
+ # Cable hands every broadcast to a worker pool, so two sent a
53
+ # millisecond apart can reach the page in either order; the client
54
+ # applies them by number. Counted per subscription from 1, on the
55
+ # graph thread alone. A refresh carries none: it fetches the page, so
56
+ # its order does not matter. Part of the private data-hibiki contract.
57
+ def render_stamp
58
+ @__hibiki_renders = @__hibiki_renders.to_i + 1
59
+ { "data-hibiki-seq": @__hibiki_renders, "data-hibiki-from": "#{self.class.name}/#{params[:cid]}" }
60
+ end
50
61
  end
51
62
  end
52
63
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.17.0"
5
+ VERSION = "0.17.1"
6
6
  end
7
7
  end
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.17.0
4
+ version: 0.17.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska