hibiki_rails 0.18.0 → 0.19.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: 915a98a11ca62685f60697d27383e8592978e0c2173b0e984c24eca7736cef55
4
- data.tar.gz: 070cd14175c3027b164ec0b52378b59178499f3f3474fe3296722f6432c580ab
3
+ metadata.gz: 0ab077d178d71a16ddf8d0f5706179f7fbac93c4d851aae6e3ccda4ef0e4b674
4
+ data.tar.gz: 409e833560a3393d2ca3c0121c2e958f3d8bb986a0b62aaaed653fe0c0f06af3
5
5
  SHA512:
6
- metadata.gz: 5d07fa8e0a897a21cce85e559e42a02dc78090f89986bc37c24202a36fb81322287a1ae86fb4c5166aa615a4ee8924732bb63020643a2e4db8e8ec72a5d870eb
7
- data.tar.gz: 993b20b7af2d105e5c067823621eb8050ff426a57a9ac0b0aacccbd2a9fd9136a9a2a6f0f635d7932952f7c4c58bef2645492c67fe1b79acbb28d1c2e86f5a37
6
+ metadata.gz: 552f5913948b16d8ed030b1eb12c35f7f28f0f109f31807dbbaf86f9b2320c3929c959c2872800785b635b9c05229df0ebe5c218e4429b490442ee2cb81ba133
7
+ data.tar.gz: 0ff3466d160bd5074a8d02530c87222fd8dcc5aeed43b033fb8ff3549decd55423286e1757511d352f39a5df47a4d60198dd0efb9fa254479febf43f1ea7d8c6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,46 @@ 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.19.0 — 2026-10-12
8
+
9
+ ### Added
10
+
11
+ **History entries.** `transmit_url` takes `history: :push`, and each move of
12
+ the URL becomes an entry in the browser's history. Use it for state that
13
+ reads as a place, such as a wizard's step. The default, `history: :replace`,
14
+ is unchanged.
15
+
16
+ ```ruby
17
+ transmit_url(history: :push) { wizard_path(step: @step.value) }
18
+ ```
19
+
20
+ **The `popstate` event.** `on(:visit, event: :popstate)` on the island root
21
+ answers Back and Forward over those entries. The client performs the action
22
+ with the query of the address the browser moved to.
23
+
24
+ ```erb
25
+ <%= island WizardChannel, **on(:visit, event: :popstate) do |cid| %>
26
+ ```
27
+
28
+ ```ruby
29
+ def visit(data) = turn_to(data["step"])
30
+ ```
31
+
32
+ - The params come from the address bar. Check them as you would a subscribe
33
+ param.
34
+ - A pushed URL becomes an entry only when it answers an action of this tab.
35
+ The first URL a channel sends, and one that follows another session's
36
+ write, replace as before.
37
+ - If the action refuses the address, or changes nothing, the bar is put back
38
+ to the URL the channel last sent. No entry is added.
39
+ - The payload is the flat query. A key ending in `[]` arrives as an array.
40
+ - `popstate` takes no `debounce:` or `fallback:`.
41
+ - Without a `popstate` action, Back over a pushed entry reloads the page at
42
+ that address. The page shows the right state if it reads the same params
43
+ on its first render, as a mirrored URL already requires.
44
+ - Coming back to a pushed entry from another page also reloads it.
45
+ - A page that has pushed an entry is left out of Turbo's snapshot cache.
46
+
7
47
  ## 0.18.0 — 2026-10-11
8
48
 
9
49
  ### Added
@@ -93,7 +93,9 @@
93
93
  //
94
94
  // The left side of `->` is a hibiki event name: click, change, input,
95
95
  // and submit are delegated DOM listeners; `visible` is a pseudo-event
96
- // backed by an IntersectionObserver (the element entering the viewport).
96
+ // backed by an IntersectionObserver (the element entering the viewport);
97
+ // `popstate` is the window's, heard for the history entries a pushed
98
+ // `{ url }` frame made.
97
99
  // Everything that is not "which event" is a sibling attribute, so the
98
100
  // token grammar never has to grow.
99
101
  //
@@ -185,6 +187,27 @@ let consumer
185
187
  // island pins nothing even if disconnect never ran.
186
188
  const islands = new WeakMap()
187
189
 
190
+ // History entries a pushed `{ url }` frame made, by the key in their state,
191
+ // and the one window listener that answers Back and Forward over them.
192
+ //
193
+ // Turbo hears popstate first, whatever the listener's phase: the browser
194
+ // runs a window's listeners in the order they were added. It answers an
195
+ // entry carrying its own state with a restore visit, and an entry without
196
+ // it by adopting it in place. So these entries carry only the key. The
197
+ // island that made one answers it (pop). An entry whose island has left
198
+ // the page, or declares no `popstate`, is loaded afresh: Turbo has by then
199
+ // filed a snapshot of the page on show under the entry's address, and a
200
+ // new document is the one thing that leaves none of that behind.
201
+ //
202
+ // Always listening, not from the first push: after a reload the browser
203
+ // still counts the entries the old document pushed as this document's, and
204
+ // answers Back over them with a popstate no island here made.
205
+ const pushers = new Map()
206
+ window.addEventListener("popstate", (event) => {
207
+ const key = event.state?.hibiki
208
+ if (key && !pushers.get(key)?.pop?.(event)) window.location.reload()
209
+ })
210
+
188
211
  // The generic island CONTAINING element, or undefined. The walk performOn
189
212
  // uses, exported for the motion module's `own` policy — which reads the
190
213
  // island's trip records, so a ChannelController subclass (not in the map)
@@ -310,6 +333,9 @@ export class ChannelController extends Controller {
310
333
  this.settledAt = -Infinity
311
334
  this.lastControl = null
312
335
  this.queued = []
336
+ // Marks the history entries this island made; see replaceUrl().
337
+ this.historyKey = `${Date.now()}-${Math.random()}`
338
+ this.popping = undefined
313
339
  this.setState("connecting")
314
340
  }
315
341
 
@@ -339,6 +365,8 @@ export class ChannelController extends Controller {
339
365
 
340
366
  disconnect() {
341
367
  this.aborted = true
368
+ pushers.delete(this.historyKey)
369
+ this.popping = undefined // the bar is the next page's now
342
370
  this.settleAll()
343
371
  this.subscription?.unsubscribe()
344
372
  this.subscription = undefined
@@ -503,6 +531,7 @@ export class ChannelController extends Controller {
503
531
  }
504
532
 
505
533
  settle(seq) {
534
+ if (this.popping === seq) this.popped()
506
535
  const record = this.busy.get(seq)
507
536
  if (!record) return
508
537
  clearTimeout(record.ceiling)
@@ -595,7 +624,8 @@ export class ChannelController extends Controller {
595
624
  // textContent assignment keeps values text-only and preserves each
596
625
  // site's own tag/classes, so per-placeholder styling survives updates.
597
626
  //
598
- // { url } — mirror graph state into the address bar (transmit_url).
627
+ // { url } — mirror graph state into the address bar (transmit_url);
628
+ // with `push`, as a history entry.
599
629
  //
600
630
  // { html } — a fragment: swap it in by its root id. The swap is holdable
601
631
  // the way Turbo's is: `hibiki:before-render` bubbles from the island
@@ -607,7 +637,7 @@ export class ChannelController extends Controller {
607
637
  //
608
638
  // Anything else is not ours to interpret. Subclasses may override, but
609
639
  // should call super (or handle `value`) to keep reactive values live.
610
- received({ html, value, url }) {
640
+ received({ html, value, url, push }) {
611
641
  if (value) {
612
642
  const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
613
643
  for (const site of document.querySelectorAll(selector)) {
@@ -615,7 +645,7 @@ export class ChannelController extends Controller {
615
645
  }
616
646
  return
617
647
  }
618
- if (url !== undefined) return this.replaceUrl(url)
648
+ if (url !== undefined) return this.replaceUrl(url, push)
619
649
  if (!html) return
620
650
  const template = document.createElement("template")
621
651
  template.innerHTML = html
@@ -633,15 +663,48 @@ export class ChannelController extends Controller {
633
663
  return event.detail.render()
634
664
  }
635
665
 
636
- // replaceState, never pushState: the URL is a mirror of graph state, not
637
- // a history entry — Back needs no popstate handling and leaves the page
638
- // normally. Same-origin only, so a channel can move the bar solely
639
- // within its own app (replaceState would throw on a cross-origin URL;
640
- // refusing keeps it silent and intentional).
641
- replaceUrl(url) {
666
+ // replaceState unless the channel asked for entries: the URL is a mirror
667
+ // of graph state, and Back leaves the page normally. Same-origin only, so
668
+ // a channel can move the bar solely within its own app (replaceState
669
+ // would throw on a cross-origin URL; refusing keeps it silent and
670
+ // intentional).
671
+ //
672
+ // A pushed URL becomes an entry only when it answers this tab's own
673
+ // action, so neither the first frame nor another session's write adds
674
+ // one, and never while a Back is being answered: an action that refuses
675
+ // the popped URL corrects the bar in place. The entry it leaves and the
676
+ // one it makes carry this island's key in place of Turbo's state; see
677
+ // `pushers`. The page is also kept out of Turbo's snapshot cache from
678
+ // then on, since Turbo no longer knows which address a snapshot is of.
679
+ replaceUrl(url, push = false) {
642
680
  const resolved = new URL(url, window.location.href)
643
681
  if (resolved.origin !== window.location.origin) return
644
- history.replaceState(history.state, "", resolved)
682
+ this.url = resolved.href
683
+ if (!push || this.popping !== undefined || !this.own()) {
684
+ return history.replaceState(history.state, "", resolved)
685
+ }
686
+ if (resolved.href === window.location.href) return
687
+ pushers.set(this.historyKey, this)
688
+ window.Turbo?.cache?.exemptPageFromCache?.()
689
+ history.replaceState(this.entry(), "")
690
+ history.pushState(this.entry(), "", resolved)
691
+ }
692
+
693
+ // The state of an entry this island answers for: what is there now,
694
+ // less Turbo's part.
695
+ entry() {
696
+ const { turbo, ...rest } = history.state ?? {}
697
+ return { ...rest, hibiki: this.historyKey }
698
+ }
699
+
700
+ // The Back being answered is over. An action that changed nothing sent no
701
+ // URL, so the bar still shows the popped one: put back the last the
702
+ // channel gave, which is the state the page is in.
703
+ popped() {
704
+ this.popping = undefined
705
+ if (this.url && this.url !== window.location.href) {
706
+ history.replaceState(history.state, "", this.url)
707
+ }
645
708
  }
646
709
 
647
710
  // `static channel = "..."` wins; otherwise infer Rails-style from the
@@ -733,6 +796,7 @@ const KEYS = {
733
796
  }
734
797
  const KEYED = '[data-hibiki-on*="keydown."]'
735
798
  const HOTKEYED = '[data-hibiki-on*="@window"]'
799
+ const POPPED = '[data-hibiki-on*="popstate->"]'
736
800
  const ISLAND = '[data-controller~="hibiki"]'
737
801
 
738
802
  // A key press that is a gesture: not part of an IME composition (Safari
@@ -1218,6 +1282,31 @@ export default class HibikiController extends ChannelController {
1218
1282
  }
1219
1283
  }
1220
1284
 
1285
+ // Back or Forward landed on an entry this island made, or the one it
1286
+ // first pushed from: the control declaring `popstate`, the root as a
1287
+ // rule, performs its action with the entry's query. Turbo has adopted the
1288
+ // entry by now and written its own state there, which would make the
1289
+ // next visit to it a restore; the entry's own state goes back. False
1290
+ // when nothing here answers, and the page is loaded afresh instead.
1291
+ pop(event) {
1292
+ const control = [this.element, ...this.element.querySelectorAll(POPPED)].find(
1293
+ (element) => element.closest(ISLAND) === this.element && element.matches(POPPED)
1294
+ )
1295
+ const token = control?.dataset.hibikiOn.split(/\s+/).find((t) => t.startsWith("popstate->"))
1296
+ if (!token) return false
1297
+ history.replaceState(event.state, "")
1298
+ const payload = control.dataset.hibikiWith ? JSON.parse(control.dataset.hibikiWith) : {}
1299
+ const query = new URLSearchParams(window.location.search)
1300
+ for (const key of new Set(query.keys())) {
1301
+ const all = query.getAll(key)
1302
+ payload[payloadKey(key)] = key.endsWith("[]") ? all : all.at(-1)
1303
+ }
1304
+ this.flush()
1305
+ this.popping = this.perform(token.slice(10), payload)
1306
+ if (this.popping === undefined) this.popped() // offline: nothing will answer
1307
+ return true
1308
+ }
1309
+
1221
1310
  // A matched key is the control's gesture, so its default goes. A fallback
1222
1311
  // control on an island that is not ready stands aside: a key has no
1223
1312
  // native destination, but its default may be the form's own submit.
@@ -30,11 +30,23 @@ module Hibiki
30
30
  # other public method would be picked up by Channel#action_methods and
31
31
  # become client-invocable.
32
32
  module Channel
33
+ # #transmit_url's history option, allowlisted so a typo names itself.
34
+ HISTORIES = %i[replace push].freeze
35
+ private_constant :HISTORIES
36
+
33
37
  def self.included(base)
34
38
  base.extend(ClassMethods)
35
39
  base.include(Broadcasts)
36
40
  end
37
41
 
42
+ # What a history mode adds to the `{ url: }` frame.
43
+ def self.url_frame(history)
44
+ return history == :push ? { push: true } : {} if HISTORIES.include?(history)
45
+
46
+ raise ArgumentError,
47
+ "transmit_url history #{history.inspect} must be one of #{HISTORIES.map(&:inspect).join(', ')}"
48
+ end
49
+
38
50
  module ClassMethods
39
51
  # Lifecycle hooks, never client-invocable actions. Performing
40
52
  # "build_graph" would rebuild the graph and leak the old root;
@@ -177,24 +189,30 @@ module Hibiki
177
189
  end
178
190
 
179
191
  # Mirror graph state into the page's address bar: wraps the block in
180
- # an effect that transmits `{ url: }`; the packaged client
181
- # history.replaceState's the bar to it. replaceState, never pushState —
182
- # the URL is a MIRROR of state, not a history entry, so Back needs no
183
- # popstate choreography and leaves the page normally. Compute a
184
- # same-origin path ("/songs?page=2" — the client refuses anything
185
- # else), and omit params that hold their defaults, or the bar grows
186
- # noise on the first transmit. Pair it with first-paint code that READS
187
- # those params (controller + subscribe-params seeding), so the mirrored
188
- # URL reloads to the state it names. Equality-gated like
189
- # #transmit_value: re-runs track every signal read, re-sends only when
190
- # the URL actually moved.
191
- def transmit_url(&compute)
192
+ # an effect that transmits `{ url: }`, and the packaged client moves
193
+ # the bar to it. Compute a same-origin path ("/songs?page=2" — the
194
+ # client refuses anything else), and omit params that hold their
195
+ # defaults, or the bar grows noise on the first transmit. Pair it with
196
+ # first-paint code that READS those params (controller +
197
+ # subscribe-params seeding), so the mirrored URL reloads to the state
198
+ # it names. Equality-gated like #transmit_value: re-runs track every
199
+ # signal read, re-sends only when the URL actually moved.
200
+ #
201
+ # history: :replace (the default) is replaceState: the URL is a MIRROR
202
+ # of state, not a history entry, so Back leaves the page normally.
203
+ # history: :push makes each move an entry, for state that reads as a
204
+ # place (a wizard's step). Back then needs an answer: put
205
+ # `on(:visit, event: :popstate)` on the island root, and the client
206
+ # performs that action with the popped URL's query params. They come
207
+ # from the address bar, so treat them like a subscribe param.
208
+ def transmit_url(history: :replace, &compute)
209
+ frame = Channel.url_frame(history)
192
210
  last = Object.new
193
211
  Hibiki::Effect.new do
194
212
  url = compute.call.to_s
195
213
  unless url == last
196
214
  last = url
197
- transmit({ url: })
215
+ transmit({ url: }.merge(frame))
198
216
  end
199
217
  end
200
218
  end
@@ -6,6 +6,7 @@ require "securerandom"
6
6
 
7
7
  require_relative "dom_props"
8
8
  require_relative "keys"
9
+ require_relative "modifiers"
9
10
 
10
11
  module Hibiki
11
12
  module Rails
@@ -187,6 +188,10 @@ module Hibiki
187
188
  # `{ name => value }` — a checkbox its checked state, a multi-select
188
189
  # its selected values — and a submitted form contributes its FormData.
189
190
  #
191
+ # `event: :popstate` goes on the island root and answers Back and
192
+ # Forward over the entries `transmit_url(history: :push)` made; the
193
+ # payload is the popped URL's query.
194
+ #
190
195
  # Everything else is a per-control modifier, kept out of the token
191
196
  # grammar so the `->` left side stays purely "which event":
192
197
  #
@@ -221,9 +226,10 @@ module Hibiki
221
226
  action = Helpers.event_name(action)
222
227
  events = Array(event).map { Helpers.event(it) }
223
228
  debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
229
+ Modifiers.popstate!(debounce:, fallback:) if events.include?("popstate")
224
230
  tokens = events.map { "#{it}->#{action}" }.join(" ")
225
231
  { data: { hibiki_on: tokens }
226
- .merge(on_modifiers(with:, debounce:, confirm:, reset:, fallback:)) }
232
+ .merge(Modifiers.attributes(with:, debounce:, confirm:, reset:, fallback:)) }
227
233
  end
228
234
 
229
235
  # Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
@@ -305,20 +311,6 @@ module Hibiki
305
311
  def island_stream(channel, cid, content)
306
312
  safe_join([turbo_stream_from(channel.channel_name, cid), content])
307
313
  end
308
-
309
- # #on's per-control modifiers, kept out of the token grammar. Each is
310
- # omitted when it matches the client's own default, so the common call
311
- # still stamps exactly one attribute.
312
- def on_modifiers(with:, debounce:, confirm:, reset:, fallback:)
313
- {
314
- hibiki_with: (JSON.generate(with) unless with.nil?),
315
- # nonzero? returns the integer itself, so 0 compacts away.
316
- hibiki_debounce: (Integer(debounce).nonzero? if debounce),
317
- hibiki_confirm: confirm&.to_s,
318
- hibiki_reset: ("false" if reset == false),
319
- hibiki_fallback: ("true" if fallback)
320
- }.compact
321
- end
322
314
  end
323
315
  end
324
316
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hibiki
6
+ module Rails
7
+ # Helpers#on's per-control modifiers, kept out of the token grammar.
8
+ module Modifiers
9
+ # The attributes beside hibiki_on. Each is omitted when it matches the
10
+ # client's own default, so the common call still stamps exactly one.
11
+ def self.attributes(with:, debounce:, confirm:, reset:, fallback:)
12
+ {
13
+ hibiki_with: (JSON.generate(with) unless with.nil?),
14
+ # nonzero? returns the integer itself, so 0 compacts away.
15
+ hibiki_debounce: (Integer(debounce).nonzero? if debounce),
16
+ hibiki_confirm: confirm&.to_s,
17
+ hibiki_reset: ("false" if reset == false),
18
+ hibiki_fallback: ("true" if fallback)
19
+ }.compact
20
+ end
21
+
22
+ # Back is not a gesture on a control: it has nothing to wait for and
23
+ # no native destination to fall back to.
24
+ def self.popstate!(**modifiers)
25
+ given = modifiers.compact.keys
26
+ raise ArgumentError, "a popstate event takes no #{given.join(' or ')}" if given.any?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.18.0"
5
+ VERSION = "0.19.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.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -189,6 +189,7 @@ files:
189
189
  - lib/hibiki/rails/helpers.rb
190
190
  - lib/hibiki/rails/instrumentation.rb
191
191
  - lib/hibiki/rails/keys.rb
192
+ - lib/hibiki/rails/modifiers.rb
192
193
  - lib/hibiki/rails/nested_actions.rb
193
194
  - lib/hibiki/rails/reactive_form.rb
194
195
  - lib/hibiki/rails/registry.rb