hibiki_rails 0.15.0 → 0.16.0

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: ce497d3d19ee46dede8bb9973f3a408e8b724f588ee2ebce3a4c0a0e5aa998f1
4
- data.tar.gz: 1eea58578f6a73f3617a8f717cc1407023d741c1bbd1fb68f534b1fe135a4caa
3
+ metadata.gz: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
4
+ data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
5
5
  SHA512:
6
- metadata.gz: fd67705cbe7db3a1eb1ddcd5dd914e7f8377f01070d564f3dca40a96f205833ef0a442f3a74bc68a8928d6577461a8bc83427c0e0117c711d3fd5733fa478b9e
7
- data.tar.gz: eefeee042142381f1e2f1f2dba0d49d2884db32409a029d08215a9479aefce564bc7e03333c23b525ade2dc37fc3111a89ebbabd52fed6adf450bfe19c9f63bf
6
+ metadata.gz: 6c032ed168913d5c4cc0a56afdf8b6a58cd89ad61581acfda17fc6e2a8030f0ebe4a81f35e0f4ec58691b79df2e6831be4bfa75f4d850db2ff334d9504a30bbb
7
+ data.tar.gz: 79144e77c309815099be858e605ddad09c6a655ebc2b66f3e20c68fdfe6cf0dceca295bc418304ea6fb0865dfda732a9a4704018029fd8f52a39f21148e15532
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ 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.16.0 — 2026-10-07
8
+
9
+ ### Added
10
+
11
+ **`props`: DOM properties from the view.** Some state of a control has no
12
+ HTML attribute, so no re-rendered fragment could carry it: a checkbox's
13
+ `indeterminate`, a container's scroll position. A page that needed one
14
+ brought JavaScript of its own. The new helper writes the wanted properties
15
+ beside the element's other attributes, and the client assigns them when the
16
+ island connects and after every render, on both transports.
17
+
18
+ ```erb
19
+ <%= check_box_tag "all", "1", all_selected,
20
+ **on(:select_all, event: :change)
21
+ .deep_merge(props(indeterminate: some_selected)) %>
22
+ <%= tag.div class: "list", **props(scroll_top: 0, key: page) do %>
23
+ ```
24
+
25
+ - `indeterminate:` is assigned after every render, so the server's value wins
26
+ over a click (the browser clears the property when the box is clicked).
27
+ - `scroll_top:` and `scroll_left:` are assigned to a new element, and again
28
+ only when the helper's output changes, so the visitor's own scrolling
29
+ survives unrelated renders. `key:` takes any JSON value and exists to change
30
+ the output: `scroll_top: 0, key: page` scrolls back to the top on each page
31
+ change, though the `0` never changes.
32
+ - The list is an allowlist kept on both sides: an unknown name raises in Ruby
33
+ and is ignored by the client, so a fragment cannot reach `innerHTML` or an
34
+ event handler.
35
+ - `props` returns a `{ data: }` hash like `on`. Two splats of the same key do
36
+ not merge, so combine them with `deep_merge` (or Phlex's `mix`).
37
+ - The element must be inside an island. This is a client change: the scan that
38
+ re-observes `visible` sentinels after a render now assigns properties in the
39
+ same walk.
40
+
41
+ Not covered, by design: properties that already have an attribute (`value`,
42
+ `checked`, `disabled`, `open` on `<details>`) ride the fragment as before.
43
+ Modal dialogs, focus and media playback are method calls, not properties, and
44
+ are left for a later release.
45
+
46
+ ### Fixed
47
+
48
+ - A `visible` sentinel inside a page that a refresh stream
49
+ (`broadcast_refresh_effect`) morphed was not re-observed: the page morph
50
+ runs after the stream's own render returns. The island now also rescans on
51
+ `turbo:render`.
52
+
7
53
  ## 0.15.0 — 2026-09-09
8
54
 
9
55
  ### Added
@@ -44,6 +44,10 @@ const idsOf = (root) => new Set(marked(root).map((element) => element.id))
44
44
  // `own`: the island's trip records are the honest gesture signal — the
45
45
  // control of an in-flight trip, or the last control the island tracked
46
46
  // (kept past its trip's settle; a render can land after the ack's grace).
47
+ // No island is no evidence, not a plain mark: an `own` element outside any
48
+ // generic island (a ChannelController subclass over Turbo streams) never
49
+ // animates its leave, since the policy exists to keep rows that paged or
50
+ // filtered away from sliding. A plain mark is the way to always animate.
47
51
  const ownsGesture = (island, element) => {
48
52
  if (!island) return false
49
53
  for (const record of island.busy.values()) {
@@ -57,6 +57,10 @@
57
57
  // browser does what the markup says
58
58
  // value sites data-hibiki-value="<name>" reactive-value placeholder;
59
59
  // the server's transmit_value message updates every match
60
+ // prop sites data-hibiki-props='{"indeterminate":true}' DOM properties
61
+ // that have no attribute, assigned on connect and after each
62
+ // render; only the names in PROPS below, an optional "key"
63
+ // beside them existing to change the text
60
64
  //
61
65
  // The client writes the protocol's other half at runtime — read-only to
62
66
  // app code, addressed to app CSS:
@@ -142,7 +146,9 @@ const islands = new WeakMap()
142
146
  // The generic island CONTAINING element, or undefined. The walk performOn
143
147
  // uses, exported for the motion module's `own` policy — which reads the
144
148
  // island's trip records, so a ChannelController subclass (not in the map)
145
- // gets plain marks only.
149
+ // yields no records: its `own` marks never animate a leave a Turbo stream
150
+ // removes. (The transmit swap's event carries the subclass itself, so
151
+ // `own` works there once the subclass tracks its controls.)
146
152
  export function islandFor(element) {
147
153
  for (let node = element; node; node = node.parentElement) {
148
154
  const island = islands.get(node)
@@ -401,7 +407,9 @@ export class ChannelController extends Controller {
401
407
  // The most recent gesture, kept past its trip's settle for the motion
402
408
  // module's `own` policy (a render can land after the ack's grace). Set
403
409
  // before the record check: a send that died on the socket was still a
404
- // gesture. A ChannelController subclass wanting `own` calls this itself.
410
+ // gesture. A ChannelController subclass wanting `own` calls this itself —
411
+ // which serves the transmit swap only: a Turbo stream reaches the
412
+ // records through islandFor, and that walk lists generic islands alone.
405
413
  this.lastControl = control
406
414
  const record = this.busy.get(seq)
407
415
  if (!record) return
@@ -578,6 +586,40 @@ export class ChannelController extends Controller {
578
586
  }
579
587
  }
580
588
 
589
+ // The DOM properties a fragment may set (Helpers#props), and when. A
590
+ // property has no attribute, so no swap or morph carries it: the client
591
+ // assigns it after the fact. The table is the allowlist — a fragment must
592
+ // never reach innerHTML or onclick — and a value of the wrong type is
593
+ // skipped. `always` is re-assigned after every render (the browser clears
594
+ // indeterminate on click; the server's value comes back). `changed` is
595
+ // assigned only to a new element or when the attribute's text differs from
596
+ // the text last applied, so the visitor's own scrolling survives unrelated
597
+ // renders; the view's `key:` is how it says "again".
598
+ const PROPS = {
599
+ indeterminate: ["boolean", "always"],
600
+ scrollTop: ["number", "changed"],
601
+ scrollLeft: ["number", "changed"]
602
+ }
603
+ const appliedProps = new WeakMap()
604
+
605
+ function applyProps(element) {
606
+ const text = element.getAttribute("data-hibiki-props")
607
+ let wanted
608
+ try {
609
+ wanted = JSON.parse(text)
610
+ } catch {
611
+ return
612
+ }
613
+ if (!wanted || typeof wanted !== "object") return
614
+ const fresh = appliedProps.get(element) !== text
615
+ for (const [name, [type, policy]] of Object.entries(PROPS)) {
616
+ const value = wanted[name]
617
+ if (typeof value !== type || (type === "number" && !Number.isFinite(value))) continue
618
+ if (policy === "always" || fresh) element[name] = value
619
+ }
620
+ appliedProps.set(element, text)
621
+ }
622
+
581
623
  // The generic controller: adds the data-hibiki-* wire protocol on top of
582
624
  // the base's plumbing.
583
625
  export default class HibikiController extends ChannelController {
@@ -607,6 +649,12 @@ export default class HibikiController extends ChannelController {
607
649
  // rather than from the middle of it.
608
650
  this.prepareTransport()
609
651
 
652
+ // First paint, so before the subscription: a property is right while
653
+ // the island is still connecting, and stays right if it never does.
654
+ for (const site of this.element.querySelectorAll("[data-hibiki-props]")) {
655
+ applyProps(site)
656
+ }
657
+
610
658
  // Root-scoped delegation (bound to the island, not document): controls
611
659
  // inside server-replaced fragments keep working with no rebinding.
612
660
  // Set up synchronously so disconnect can always tear them down.
@@ -639,7 +687,7 @@ export default class HibikiController extends ChannelController {
639
687
  }
640
688
  })
641
689
 
642
- // Re-scan at the two points a fragment can be swapped under us, rather
690
+ // Re-scan at the points a fragment can be swapped under us, rather
643
691
  // than blanket-observing the document: a MutationObserver over the page
644
692
  // is a real per-mutation cost paid by every app on it.
645
693
  this.streamRender = (event) => {
@@ -652,14 +700,19 @@ export default class HibikiController extends ChannelController {
652
700
  // — which at worst settles an already-acked trip a few ms early,
653
701
  // never one that has not been acked at all.
654
702
  this.renders++
655
- this.scanSentinels()
703
+ this.scan()
656
704
  }
657
705
  }
658
706
  document.addEventListener("turbo:before-stream-render", this.streamRender)
707
+ // A refresh stream morphs the page after its own render has returned,
708
+ // so the wrapper above scans too early for it; turbo:render follows
709
+ // the morph.
710
+ this.pageRender = () => this.scan()
711
+ document.addEventListener("turbo:render", this.pageRender)
659
712
 
660
713
  await this.openSubscription()
661
714
  if (this.aborted) return
662
- this.scanSentinels()
715
+ this.scan()
663
716
  }
664
717
 
665
718
  disconnect() {
@@ -668,6 +721,7 @@ export default class HibikiController extends ChannelController {
668
721
  this.element.removeEventListener(type, handler)
669
722
  }
670
723
  document.removeEventListener("turbo:before-stream-render", this.streamRender)
724
+ document.removeEventListener("turbo:render", this.pageRender)
671
725
  for (const id of this.pending) clearTimeout(id)
672
726
  this.pending.clear()
673
727
  this.observer.disconnect()
@@ -679,17 +733,28 @@ export default class HibikiController extends ChannelController {
679
733
  received(data) {
680
734
  const swapped = super.received(data)
681
735
  if (!data.html) return
682
- if (swapped?.then) swapped.then(() => this.scanSentinels())
683
- else this.scanSentinels()
684
- }
685
-
686
- // Observe every `visible->` sentinel this island owns. observe() is a
687
- // no-op for an element already being observed, so re-scanning is cheap
688
- // and cannot double-fire a sentinel that merely stayed put.
689
- scanSentinels() {
690
- for (const control of this.element.querySelectorAll('[data-hibiki-on*="visible->"]')) {
691
- if (control.closest('[data-controller~="hibiki"]') === this.element) {
692
- this.observer.observe(control)
736
+ if (swapped?.then) swapped.then(() => this.scan())
737
+ else this.scan()
738
+ }
739
+
740
+ // What a render leaves for the client to finish, in one walk of the
741
+ // island: assign every prop site's properties, and observe every
742
+ // `visible->` sentinel this island owns. observe() is a no-op for an
743
+ // element already being observed, so re-scanning is cheap and cannot
744
+ // double-fire a sentinel that merely stayed put. Props need no ownership
745
+ // check: assigning one twice, from a nested island and its parent, lands
746
+ // the same value.
747
+ scan() {
748
+ const found = this.element.querySelectorAll(
749
+ '[data-hibiki-on*="visible->"], [data-hibiki-props]'
750
+ )
751
+ for (const element of found) {
752
+ if (element.hasAttribute("data-hibiki-props")) applyProps(element)
753
+ if (
754
+ element.dataset.hibikiOn?.includes("visible->") &&
755
+ element.closest('[data-controller~="hibiki"]') === this.element
756
+ ) {
757
+ this.observer.observe(element)
693
758
  }
694
759
  }
695
760
  }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ # The DOM properties Helpers#props may set, and their wire form. The
6
+ # client keeps the same list (PROPS in hibiki.js) and ignores anything
7
+ # outside it, so a fragment can never reach innerHTML or an event
8
+ # handler. Add a property in both places.
9
+ module DomProps
10
+ # Ruby key => [wire name, type].
11
+ ALLOWED = {
12
+ indeterminate: ["indeterminate", :boolean],
13
+ scroll_top: ["scrollTop", :number],
14
+ scroll_left: ["scrollLeft", :number]
15
+ }.freeze
16
+
17
+ # One name/value as its wire pair, or nil when there is nothing to
18
+ # send. A boolean is always sent, false included: a morph keeps the
19
+ # element, so an omitted false would leave the property set.
20
+ def self.pair(name, value)
21
+ wire, type = ALLOWED.fetch(name.to_sym) do
22
+ raise ArgumentError,
23
+ "DOM property #{name.inspect} must be one of #{ALLOWED.keys.join(', ')}"
24
+ end
25
+ return [wire, value ? true : false] if type == :boolean
26
+ return if value.nil?
27
+ return [wire, value] if value.is_a?(Numeric) && value.to_f.finite?
28
+
29
+ raise ArgumentError, "DOM property #{name} must be a finite number, got #{value.inspect}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -4,6 +4,8 @@ require "cgi/escape"
4
4
  require "json"
5
5
  require "securerandom"
6
6
 
7
+ require_relative "dom_props"
8
+
7
9
  module Hibiki
8
10
  module Rails
9
11
  # Opt-in helpers that stamp the packaged client's attribute protocol
@@ -230,6 +232,34 @@ module Hibiki
230
232
  # Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
231
233
  def reactive_attrs(name) = { data: { hibiki_value: Helpers.value_name(name) } }
232
234
 
235
+ # DOM properties for this element — state that has no HTML attribute,
236
+ # so no fragment can carry it. The client assigns them when the island
237
+ # connects and after every render.
238
+ #
239
+ # tag.input type: "checkbox", **props(indeterminate: some_selected)
240
+ # tag.div class: "list", **props(scroll_top: 0, key: query)
241
+ #
242
+ # indeterminate: true/false. Assigned after every render, so the
243
+ # server's value wins over a click.
244
+ # scroll_top:, scroll_left: pixels, nil to leave alone. Assigned to
245
+ # a new element, and again only when this helper's
246
+ # output changes — the visitor's own scrolling is
247
+ # kept across unrelated renders.
248
+ # key: any JSON value, sent only to change the output:
249
+ # `scroll_top: 0, key: query` scrolls back to the top
250
+ # each time the query changes.
251
+ #
252
+ # The element must be inside an island. Returns a `{ data: }` hash
253
+ # like #on; two splats of one key do not merge, so on an element that
254
+ # has both: `**on(:select_all, event: :change).deep_merge(props(...))`.
255
+ def props(key: nil, **properties)
256
+ wanted = properties.filter_map { |name, value| DomProps.pair(name, value) }.to_h
257
+ return { data: {} } if wanted.empty?
258
+
259
+ wanted["key"] = key unless key.nil?
260
+ { data: { hibiki_props: JSON.generate(wanted) } }
261
+ end
262
+
233
263
  private
234
264
 
235
265
  # #island's preconditions: a block, an ActionView receiver, a channel
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.15.0"
5
+ VERSION = "0.16.0"
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.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -183,6 +183,7 @@ files:
183
183
  - lib/hibiki/rails/broadcasts.rb
184
184
  - lib/hibiki/rails/channel.rb
185
185
  - lib/hibiki/rails/debounce.rb
186
+ - lib/hibiki/rails/dom_props.rb
186
187
  - lib/hibiki/rails/engine.rb
187
188
  - lib/hibiki/rails/graph_actor.rb
188
189
  - lib/hibiki/rails/helpers.rb