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 +4 -4
- data/CHANGELOG.md +46 -0
- data/app/assets/javascripts/hibiki-motion.js +4 -0
- data/app/assets/javascripts/hibiki.js +81 -16
- data/lib/hibiki/rails/dom_props.rb +33 -0
- data/lib/hibiki/rails/helpers.rb +30 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7bfeee9d4e44c3404ac6582548e0bdb46ae139cfa26eec2c71b7d8b95c40c77
|
|
4
|
+
data.tar.gz: ab0505b42c8450e0a8014f94c380ed2f3e995448a9cf8cfc8ed16f4eb450d524
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
683
|
-
else this.
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -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
|
data/lib/hibiki/rails/version.rb
CHANGED
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.
|
|
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
|