phlex-reactive 0.14.4 → 0.15.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 +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +211 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +36 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +310 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +94 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +118 -0
- data/lib/phlex/reactive/streamable.rb +12 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +11 -1
|
@@ -127,6 +127,10 @@ module Phlex
|
|
|
127
127
|
(on_complete = self.class.reactive_on_complete_attr)
|
|
128
128
|
data[:reactive_on_complete] = on_complete
|
|
129
129
|
end
|
|
130
|
+
# Presence (issue #358): the signed src of the roster's presence
|
|
131
|
+
# stream, from which the client mints its subscription. Only a class
|
|
132
|
+
# that declares reactive_presence has the method.
|
|
133
|
+
data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
|
|
130
134
|
{ data: }
|
|
131
135
|
end
|
|
132
136
|
|
|
@@ -280,6 +284,17 @@ module Phlex
|
|
|
280
284
|
# (trailing-edge) — passing both raises ArgumentError.
|
|
281
285
|
# div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
|
|
282
286
|
#
|
|
287
|
+
# `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
|
|
288
|
+
# chord: a window keydown whose `mod` is meta on Apple platforms and
|
|
289
|
+
# ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
|
|
290
|
+
# runs the one for its platform). Unlike a plain window binding, the
|
|
291
|
+
# client prevents the chord's native default, and it skips the chord
|
|
292
|
+
# while focus is in an input, textarea, select or contenteditable
|
|
293
|
+
# (native text undo keeps working) unless the Hash form opts in. It
|
|
294
|
+
# decides event:, window: and outside:, so passing one raises.
|
|
295
|
+
# span(**on(:undo, shortcut: "mod+z"))
|
|
296
|
+
# span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
|
|
297
|
+
#
|
|
283
298
|
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
284
299
|
# COSMETIC hints the client applies the instant the trigger fires and
|
|
285
300
|
# REVERTS if the round trip fails, so a click/toggle gives instant feedback
|
|
@@ -324,9 +339,9 @@ module Phlex
|
|
|
324
339
|
# busy_on for scoped indicators.
|
|
325
340
|
# button(**on(:save, busy: "Saving…")) { "Save" }
|
|
326
341
|
# button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
|
|
327
|
-
def on(action_name, event:
|
|
342
|
+
def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
|
|
328
343
|
window: false, once: false, outside: false, optimistic: nil, busy: nil,
|
|
329
|
-
**params)
|
|
344
|
+
shortcut: nil, **params)
|
|
330
345
|
reject_removed_on_kwargs!(action_name, params)
|
|
331
346
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
332
347
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
@@ -362,8 +377,11 @@ module Phlex
|
|
|
362
377
|
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
363
378
|
end
|
|
364
379
|
|
|
365
|
-
|
|
366
|
-
|
|
380
|
+
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
381
|
+
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
382
|
+
event ||= "click"
|
|
383
|
+
window_bound = window || outside || !shortcut.nil?
|
|
384
|
+
action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
|
|
367
385
|
attrs = {
|
|
368
386
|
data: {
|
|
369
387
|
action:,
|
|
@@ -384,6 +402,9 @@ module Phlex
|
|
|
384
402
|
# The client decides preventDefault behavior from event.params (never by
|
|
385
403
|
# sniffing the descriptor), so EVERY window binding flags the param.
|
|
386
404
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
405
|
+
# Issue #357: the client reads the shortcut to pick the twin for its
|
|
406
|
+
# platform, skip editable targets and prevent the chord.
|
|
407
|
+
attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
|
|
387
408
|
# Force type="button" for click triggers so a bare button inside a <form>
|
|
388
409
|
# can't submit it — EXCEPT when checked: :keep is declared: that hint's
|
|
389
410
|
# whole point is to let a click-bound checkbox/radio flip natively, and a
|
|
@@ -406,7 +427,9 @@ module Phlex
|
|
|
406
427
|
# modifiers (#80): outside-click-to-close a dropdown is
|
|
407
428
|
# div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
|
|
408
429
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
409
|
-
# the forced type="button".
|
|
430
|
+
# the forced type="button". `shortcut:` (issue #357) works as on()'s, on
|
|
431
|
+
# a bare :keydown only:
|
|
432
|
+
# div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
|
|
410
433
|
#
|
|
411
434
|
# Issue #271: each call emits ONE self-describing binding record —
|
|
412
435
|
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
@@ -437,7 +460,8 @@ module Phlex
|
|
|
437
460
|
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
438
461
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
439
462
|
# browser.
|
|
440
|
-
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil
|
|
463
|
+
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
464
|
+
shortcut: nil)
|
|
441
465
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
442
466
|
raise ArgumentError,
|
|
443
467
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -454,11 +478,15 @@ module Phlex
|
|
|
454
478
|
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
455
479
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
456
480
|
end
|
|
457
|
-
|
|
481
|
+
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
482
|
+
window_bound = window || outside || !shortcut.nil?
|
|
458
483
|
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
|
|
484
|
+
# The record's `on` stays the bare keydown: the client reads a key
|
|
485
|
+
# filter from `on`, and `mod` is not a key.
|
|
486
|
+
record["shortcut"] = shortcut.param if shortcut
|
|
459
487
|
attrs = {
|
|
460
488
|
data: {
|
|
461
|
-
action:
|
|
489
|
+
action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
|
|
462
490
|
# Space-free (issue #271): compact JSON has no structural
|
|
463
491
|
# whitespace, so every space is inside a string, where \u0020
|
|
464
492
|
# parses to the same string — and mix's space-join stays splittable.
|
|
@@ -1732,6 +1760,64 @@ module Phlex
|
|
|
1732
1760
|
"on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
|
|
1733
1761
|
end
|
|
1734
1762
|
|
|
1763
|
+
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|
|
1764
|
+
# decides the event, the window binding and the containment rule, so
|
|
1765
|
+
# an explicit event:/window:/outside: next to it is a mistake.
|
|
1766
|
+
def shortcut_for!(caller, shortcut, event:, window:, outside:)
|
|
1767
|
+
{ "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
|
|
1768
|
+
next unless value
|
|
1769
|
+
|
|
1770
|
+
raise ArgumentError,
|
|
1771
|
+
"#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
|
|
1772
|
+
"binding; drop #{name}"
|
|
1773
|
+
end
|
|
1774
|
+
Phlex::Reactive::Shortcut.parse(shortcut)
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
# on_client's event is positional: with shortcut: it must be the bare
|
|
1778
|
+
# keydown (the key filter comes from the shortcut).
|
|
1779
|
+
def client_shortcut!(event, shortcut, window:, outside:)
|
|
1780
|
+
return if shortcut.nil?
|
|
1781
|
+
|
|
1782
|
+
unless event == "keydown"
|
|
1783
|
+
raise ArgumentError,
|
|
1784
|
+
"on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
|
|
1785
|
+
"(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
|
|
1786
|
+
end
|
|
1787
|
+
shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
# The data-action descriptors of an on()/on_client trigger: one, or a
|
|
1791
|
+
# shortcut's meta and ctrl twins (space-joined, as mix joins them).
|
|
1792
|
+
# The common, shortcut-less path is ONE interpolation (on() is a hot path).
|
|
1793
|
+
# A chord whose key Stimulus lacks (issue #366: delete, backspace) binds a
|
|
1794
|
+
# bare window keydown: the client's shortcut check matches it alone.
|
|
1795
|
+
def trigger_descriptors(event, shortcut, window_bound, method, once)
|
|
1796
|
+
return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
|
|
1797
|
+
return bare_shortcut_descriptor(shortcut, method, once) unless shortcut.stimulus_filterable?
|
|
1798
|
+
|
|
1799
|
+
shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
# Stimulus removes a `:once` listener after its FIRST event, and a bare
|
|
1803
|
+
# keydown hears every key, so the first keypress of any kind would spend it.
|
|
1804
|
+
def bare_shortcut_descriptor(shortcut, method, once)
|
|
1805
|
+
if once
|
|
1806
|
+
raise ArgumentError,
|
|
1807
|
+
"shortcut: #{shortcut.keys.inspect} cannot take once: — its key is matched by the " \
|
|
1808
|
+
"library, not Stimulus, so the first key pressed of any kind would spend the binding"
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
"keydown@window->reactive##{method}"
|
|
1812
|
+
end
|
|
1813
|
+
|
|
1814
|
+
# A Hash param is JSON (Stimulus's param reader parses it back into an
|
|
1815
|
+
# object); the String form stays a plain string.
|
|
1816
|
+
def shortcut_param_attr(shortcut)
|
|
1817
|
+
param = shortcut.param
|
|
1818
|
+
param.is_a?(Hash) ? param.to_json : param
|
|
1819
|
+
end
|
|
1820
|
+
|
|
1735
1821
|
# Normalize + validate a pending-state hint (issue #181), returning its
|
|
1736
1822
|
# JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
|
|
1737
1823
|
# :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The reactive_presence macro (issue #358). Not named `Presence`: this
|
|
7
|
+
# module is in every reactive component's constant lookup, where a bare
|
|
8
|
+
# noun would shadow a host app's kit component of that name (the #233
|
|
9
|
+
# rule). The behaviour it adds lives in Phlex::Reactive::Presence.
|
|
10
|
+
module PresenceDeclaration
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
# A presence name is the last part of the stream name, so it is one
|
|
14
|
+
# identifier: no colon (the stream-name separator), no spaces.
|
|
15
|
+
PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Declare this component the roster of a presence stream:
|
|
19
|
+
# [record, :presence, name], or [*key, :presence, name] for a
|
|
20
|
+
# record-less component.
|
|
21
|
+
#
|
|
22
|
+
# reactive_record :board
|
|
23
|
+
# reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
24
|
+
#
|
|
25
|
+
# `state:` is each tab's ephemeral state, typed with the same table as
|
|
26
|
+
# action params; undeclared keys are dropped. `ttl:` is how many
|
|
27
|
+
# seconds a silent tab survives. Adds the presence_set action (what
|
|
28
|
+
# js.presence sends) and `presence` (the roster) to the component.
|
|
29
|
+
def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
|
|
30
|
+
require_server_actions!(:reactive_presence)
|
|
31
|
+
declaration = build_presence_declaration(name, state, ttl, key)
|
|
32
|
+
refuse_dormant_presence! if reactive_dormant?
|
|
33
|
+
# The name's fit in pgbus's stream-name budget is configuration (the
|
|
34
|
+
# queue prefix), so it is refused here; the render re-checks it.
|
|
35
|
+
transport = Phlex::Reactive::Presence::Transport
|
|
36
|
+
if transport.wired? && !transport.fits?(declaration.name)
|
|
37
|
+
raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The generated action. Exempt from verify_authorized: the signed
|
|
41
|
+
# token already proves the actor rendered this root, and the write
|
|
42
|
+
# is typed, default-deny, ephemeral and lands only on the actor's own
|
|
43
|
+
# row (keyed by its secret SSE connection id). A component that needs
|
|
44
|
+
# a check defines authorize_presence_state!(**state); the action runs
|
|
45
|
+
# it first.
|
|
46
|
+
action :presence_set, params: declaration.state
|
|
47
|
+
skip_verify_authorized :presence_set
|
|
48
|
+
Registry.write_scalar(self, :presence, declaration)
|
|
49
|
+
include Phlex::Reactive::Presence::ComponentMethods
|
|
50
|
+
|
|
51
|
+
# Class load runs before a database exists: the signatures alone gate it.
|
|
52
|
+
transport.register_ttl(declaration.name, ttl) if transport.wired?
|
|
53
|
+
declaration
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Phlex::Reactive::Presence::Declaration, or nil.
|
|
57
|
+
def reactive_presence_declaration
|
|
58
|
+
Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The presence stream name a roster of `record` subscribes to — what
|
|
62
|
+
# streams_authorize sees. For a strict per-record check at connect
|
|
63
|
+
# time: compare it with the key of each record the user may view.
|
|
64
|
+
# A record-less roster (key:) ignores `record`.
|
|
65
|
+
def reactive_presence_stream_name_for(record = nil)
|
|
66
|
+
declaration = reactive_presence_declaration
|
|
67
|
+
subject = declaration.key || [record]
|
|
68
|
+
Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build_presence_declaration(name, state, ttl, key)
|
|
74
|
+
name = name.to_s
|
|
75
|
+
unless PRESENCE_NAME.match?(name)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
|
|
78
|
+
"name), got #{name.inspect}"
|
|
79
|
+
end
|
|
80
|
+
unless ttl.is_a?(Integer) && ttl.positive?
|
|
81
|
+
raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
|
|
82
|
+
"got #{ttl.inspect}"
|
|
83
|
+
end
|
|
84
|
+
refuse_ttl_within_heartbeat!(ttl)
|
|
85
|
+
|
|
86
|
+
state = state.transform_keys(&:to_sym)
|
|
87
|
+
if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Phlex::Reactive::Presence::Declaration.new(
|
|
93
|
+
name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A ttl at or below the heartbeat would expire a live tab between two
|
|
98
|
+
# beats: its row would vanish and come back on the next one.
|
|
99
|
+
def refuse_ttl_within_heartbeat!(ttl)
|
|
100
|
+
heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
|
|
101
|
+
return if ttl > heartbeat
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
|
|
105
|
+
"(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
|
|
106
|
+
"(two missed beats is a good floor)."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_dormant_presence!
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
|
|
112
|
+
"would never join the stream. Drop reactive_dormant on this component."
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -66,7 +66,9 @@ module Phlex
|
|
|
66
66
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
67
67
|
# WHOLE component) plus a list of named actions.
|
|
68
68
|
skip_all: :@reactive_own_skip_all,
|
|
69
|
-
skip_actions: :@reactive_own_skip_actions
|
|
69
|
+
skip_actions: :@reactive_own_skip_actions,
|
|
70
|
+
# Presence roster — reactive_presence (issue #358).
|
|
71
|
+
presence: :@reactive_own_presence
|
|
70
72
|
}.freeze
|
|
71
73
|
|
|
72
74
|
# The identity-side hot-path memos swept by bump! (see the contract
|
data/lib/phlex/reactive/defer.rb
CHANGED
|
@@ -112,6 +112,11 @@ module Phlex
|
|
|
112
112
|
"got #{placeholder.class}"
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# The signed SSE URL a <pgbus-stream-source> subscribes to `key` with —
|
|
116
|
+
# minted off-view, as pgbus_stream_from would. Shared with presence
|
|
117
|
+
# (issue #358), whose roster subscribes to its stream the same way.
|
|
118
|
+
def stream_src(key) = signed_stream_src(key)
|
|
119
|
+
|
|
115
120
|
private
|
|
116
121
|
|
|
117
122
|
# The delivery directive: `<turbo-stream action="reactive:defer">`
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute sortable effects hints devtools].freeze
|
|
24
|
-
# (Gzipped, bun zlib level 9: the default file
|
|
25
|
-
# persist 3.1, defer
|
|
26
|
-
# 3.1, effects 1.7, hints
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
|
|
25
|
+
# persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
|
|
26
|
+
# 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
|
@@ -202,6 +202,11 @@ module Phlex
|
|
|
202
202
|
Phlex::Reactive.freeze_param_types!
|
|
203
203
|
# Freeze the named-schema registry the same way (issue #184).
|
|
204
204
|
Phlex::Reactive.freeze_param_schemas!
|
|
205
|
+
|
|
206
|
+
# Presence (issue #358): register the presence stream pattern and the
|
|
207
|
+
# change hook with the transport. A no-op without a capable pgbus — an
|
|
208
|
+
# app on Action Cable boots exactly as before.
|
|
209
|
+
Phlex::Reactive::Presence.install!
|
|
205
210
|
end
|
|
206
211
|
end
|
|
207
212
|
end
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -186,6 +186,26 @@ module Phlex
|
|
|
186
186
|
append("focus_first", target_args(to, global:))
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# --- Modal dialogs (issue #365) ---
|
|
190
|
+
#
|
|
191
|
+
# show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
|
|
192
|
+
# focus trap, ::backdrop, Escape to close and focus
|
|
193
|
+
# restored to the opener, all the browser's own. A
|
|
194
|
+
# dialog already open NON-modally is closed and
|
|
195
|
+
# reopened as a modal; one already modal is left as is.
|
|
196
|
+
# close_dialog(to) — HTMLDialogElement#close() (a closed one is left as is).
|
|
197
|
+
# A target that is not a <dialog> is skipped. Both ACTOR-ONLY like focus:
|
|
198
|
+
# opening a modal steals focus, so a broadcast refuses them — a
|
|
199
|
+
# broadcaster renders the dialog open with data: { reactive_modal: true }.
|
|
200
|
+
|
|
201
|
+
def show_modal(to, global: false)
|
|
202
|
+
append("show_modal", target_args(to, global:))
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def close_dialog(to, global: false)
|
|
206
|
+
append("close_dialog", target_args(to, global:))
|
|
207
|
+
end
|
|
208
|
+
|
|
189
209
|
# --- Submit (issue #226) ---
|
|
190
210
|
#
|
|
191
211
|
# submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
|
|
@@ -304,6 +324,30 @@ module Phlex
|
|
|
304
324
|
append("dispatch", args.freeze)
|
|
305
325
|
end
|
|
306
326
|
|
|
327
|
+
# --- Presence (issue #358) ---
|
|
328
|
+
#
|
|
329
|
+
# presence(**state) — write this tab's presence state through the root's
|
|
330
|
+
# generated presence_set action (reactive_presence): only the declared
|
|
331
|
+
# keys reach the server, typed like action params. Always targets the
|
|
332
|
+
# ROOT, which must declare reactive_presence (the client warns and does
|
|
333
|
+
# nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
|
|
334
|
+
# every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
|
|
335
|
+
# li(**on_client(:click, js.presence(selected: item.id))) { item.title }
|
|
336
|
+
def presence(**state)
|
|
337
|
+
raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
|
|
338
|
+
|
|
339
|
+
state.each do |name, value|
|
|
340
|
+
next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
|
|
341
|
+
next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
|
|
342
|
+
|
|
343
|
+
raise ArgumentError,
|
|
344
|
+
"#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
|
|
345
|
+
"#{name.inspect} is #{value.class}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
|
|
349
|
+
end
|
|
350
|
+
|
|
307
351
|
# The wire format: a JSON array of [op, args] pairs, applied in order.
|
|
308
352
|
def to_json(*)
|
|
309
353
|
@ops.to_json
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Presence
|
|
6
|
+
# What reactive_presence adds to a component (issue #358). Included only
|
|
7
|
+
# by a class that declares presence, so every other component keeps
|
|
8
|
+
# ActiveSupport's Object#presence.
|
|
9
|
+
module ComponentMethods
|
|
10
|
+
# The roster, read once per render (and again after #presence.set). The
|
|
11
|
+
# memo is namespaced: a component's own @presence must not be clobbered.
|
|
12
|
+
def presence
|
|
13
|
+
@reactive_presence_roster ||= Roster.new( # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
14
|
+
Transport.members(reactive_presence_stream_name),
|
|
15
|
+
keys: self.class.reactive_presence_declaration.state.keys,
|
|
16
|
+
connection_id: Presence.connection_id,
|
|
17
|
+
writer: -> { reactive_presence_write(it) }
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The generated action js.presence sends. Runs authorize_presence_state!
|
|
22
|
+
# first when the component defines it (raise to refuse), writes the
|
|
23
|
+
# actor's tab state, and morphs the actor's own view.
|
|
24
|
+
#
|
|
25
|
+
# The client also sends it EMPTY on every connect (the announce). That
|
|
26
|
+
# writes no value — only the reverse note (this class and the record of
|
|
27
|
+
# the token the actor already holds) on the actor's own row — so there
|
|
28
|
+
# is nothing to authorize, and the hook is skipped: its keywords may be
|
|
29
|
+
# required, and calling it with none would raise.
|
|
30
|
+
def presence_set(**state)
|
|
31
|
+
authorize_presence_state!(**state) if state.any? && respond_to?(:authorize_presence_state!, true)
|
|
32
|
+
presence.set(**state)
|
|
33
|
+
reply.morph
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# data-reactive-presence-peer / -member on a tab's element: the client
|
|
37
|
+
# marks the one that is this tab ("me") and this person's other tabs.
|
|
38
|
+
def reactive_presence_peer(peer)
|
|
39
|
+
{ data: { reactive_presence_peer: peer.seat.to_s, reactive_presence_member: peer.member_id.to_s } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# data-reactive-presence-member / -seats on a person's element.
|
|
43
|
+
def reactive_presence_member(member)
|
|
44
|
+
{ data: { reactive_presence_member: member.id.to_s, reactive_presence_seats: member.seats.size.to_s } }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# [record, :presence, name] — or [*key, :presence, name] record-less.
|
|
48
|
+
def reactive_presence_stream_parts
|
|
49
|
+
declaration = self.class.reactive_presence_declaration
|
|
50
|
+
[*reactive_presence_subject, :presence, declaration.name]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def reactive_presence_stream_name
|
|
54
|
+
@reactive_presence_stream_name ||= reactive_presence_key(reactive_presence_stream_parts)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The root's subscription marker (reactive_attrs): the signed SSE src of
|
|
58
|
+
# the presence stream. The client mints the <pgbus-stream-source> from it.
|
|
59
|
+
# And the seats this render saw: a template need not render an element
|
|
60
|
+
# per tab, so the client diffs these for reactive:presence and checks
|
|
61
|
+
# them to know its own tab is on the roster. Seats only (pgbus limits
|
|
62
|
+
# them to [A-Za-z0-9_-]), never a member or connection id.
|
|
63
|
+
def reactive_presence_data
|
|
64
|
+
Presence.require_capable!(self.class)
|
|
65
|
+
{ reactive_presence: Transport.source_src(reactive_presence_stream_name),
|
|
66
|
+
reactive_presence_roster: presence.peers.filter_map(&:seat).join(" ") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# The stream key for the parts. Only pgbus's budget error becomes a
|
|
72
|
+
# ConfigurationError (the backstop for a name the declaration could not
|
|
73
|
+
# check); a missing key keeps its own error.
|
|
74
|
+
def reactive_presence_key(parts)
|
|
75
|
+
Transport.stream_name(parts)
|
|
76
|
+
rescue ::ArgumentError => e # pgbus raises the core one; Phlex defines its own
|
|
77
|
+
raise ConfigurationError, Transport.over_budget_message(self.class, parts.last, e.message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reactive_presence_subject
|
|
81
|
+
declaration = self.class.reactive_presence_declaration
|
|
82
|
+
return declaration.key if declaration.key
|
|
83
|
+
|
|
84
|
+
record_key = self.class.reactive_record_key
|
|
85
|
+
unless record_key
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"#{self.class.name}: reactive_presence needs reactive_record (the stream is the record's) " \
|
|
88
|
+
"or key: (the raw stream key parts of a record-less roster)"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
[instance_variable_get(:"@#{record_key}")]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reactive_presence_write(state)
|
|
95
|
+
declared = self.class.reactive_presence_declaration.state.keys
|
|
96
|
+
unknown = state.keys.map(&:to_sym) - declared
|
|
97
|
+
unless unknown.empty?
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"#{self.class.name}: presence.set got undeclared state #{unknown.join(", ")} " \
|
|
100
|
+
"(declared: #{declared.join(", ")})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
connection_id = Presence.connection_id
|
|
104
|
+
return if connection_id.nil?
|
|
105
|
+
|
|
106
|
+
merged = (presence.me&.state || {}).merge(state.transform_keys(&:to_sym)).transform_keys(&:to_s)
|
|
107
|
+
written = Transport.set_state(reactive_presence_stream_name, connection_id:,
|
|
108
|
+
state: merged.merge(NOTE_KEY => reactive_presence_note))
|
|
109
|
+
@reactive_presence_roster = nil
|
|
110
|
+
# No row for this connection (it expired, or pgbus never joined it:
|
|
111
|
+
# no identity): nothing changed for the peers.
|
|
112
|
+
return if written.nil?
|
|
113
|
+
|
|
114
|
+
# A seatless actor (no ?seat=) is on no roster: nothing any viewer
|
|
115
|
+
# sees changed, so there is nothing to broadcast.
|
|
116
|
+
me = presence.me
|
|
117
|
+
return if me.nil?
|
|
118
|
+
|
|
119
|
+
Presence.broadcast(self.class.from_identity(reactive_identity_payload), exclude: connection_id)
|
|
120
|
+
me
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Which component wrote the row: its identity payload (class, record gid,
|
|
124
|
+
# declared reactive_state), which is how the render job rebuilds it from
|
|
125
|
+
# the stream (see Presence::NOTE_KEY) — the same shape a token carries.
|
|
126
|
+
def reactive_presence_note = reactive_identity_payload
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Presence
|
|
6
|
+
# The presence of one stream as one render reads it (issue #358): what
|
|
7
|
+
# `presence` returns in a roster component's view_template and actions.
|
|
8
|
+
# Built from the transport's rows once per render.
|
|
9
|
+
#
|
|
10
|
+
# A row is one SSE connection. Two presence roots in one tab are two
|
|
11
|
+
# connections of one seat, so peers are grouped by seat: one entry per tab.
|
|
12
|
+
class Roster
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
def initialize(rows, keys:, connection_id: nil, writer: nil)
|
|
16
|
+
# seat => its connection ids. Private: a connection id is the bearer
|
|
17
|
+
# key for writing that row, so only `me` reads it.
|
|
18
|
+
@connections = {}
|
|
19
|
+
@peers = build_peers(rows, keys)
|
|
20
|
+
@connection_id = connection_id
|
|
21
|
+
@writer = writer
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Every tab, in join order.
|
|
25
|
+
attr_reader :peers
|
|
26
|
+
|
|
27
|
+
def each(&) = @peers.each(&)
|
|
28
|
+
|
|
29
|
+
# The tabs grouped by person, in order of their first tab.
|
|
30
|
+
def members
|
|
31
|
+
@members ||= @peers.group_by(&:member_id).map do |id, seats|
|
|
32
|
+
Member.new(id:, metadata: seats.last.metadata, seats:)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The tabs whose state matches, e.g. where(selected: item.id).
|
|
37
|
+
def where(**state) = @peers.select { matches?(it, state) }
|
|
38
|
+
|
|
39
|
+
def count = @peers.size
|
|
40
|
+
|
|
41
|
+
alias size count
|
|
42
|
+
|
|
43
|
+
def empty? = @peers.empty?
|
|
44
|
+
|
|
45
|
+
# The actor's own tab during an action (found by its presence
|
|
46
|
+
# connection), nil in a plain render or before the tab has connected.
|
|
47
|
+
def me
|
|
48
|
+
return if @connection_id.nil?
|
|
49
|
+
|
|
50
|
+
@peers.find { @connections[it.seat].include?(@connection_id) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# In an action: write the actor's tab state (merged into what it had),
|
|
54
|
+
# morph the roster into the other viewers' tabs, return the actor's
|
|
55
|
+
# peer. nil when the actor's tab has no presence connection yet.
|
|
56
|
+
def set(**state) = @writer.call(state)
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def matches?(peer, state) = state.all? { |key, value| peer.state[key] == value }
|
|
61
|
+
|
|
62
|
+
# A row without a seat (a connection that sent no ?seat=: an app's own
|
|
63
|
+
# turbo_stream_from of the stream, a source without `presence`, a stale
|
|
64
|
+
# client) is no peer: no client can mark it "me", and keying it by its
|
|
65
|
+
# connection id would render that id — the bearer key for its row.
|
|
66
|
+
def build_peers(rows, keys)
|
|
67
|
+
rows.select { it["seat"].present? }.group_by { it["seat"] }.map do |seat, seat_rows|
|
|
68
|
+
first = seat_rows.first
|
|
69
|
+
@connections[seat] = seat_rows.map { it["connection_id"] }
|
|
70
|
+
Peer.new(
|
|
71
|
+
seat:, member_id: first["id"].to_s, metadata: seat_rows.last["metadata"] || {},
|
|
72
|
+
state: declared_state(seat_rows, keys), joined_at: first["joined_at"]
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The seat's state, declared keys only (never the reverse note), merged
|
|
78
|
+
# across its connections in join order.
|
|
79
|
+
def declared_state(seat_rows, keys)
|
|
80
|
+
seat_rows.each_with_object({}) do |row, state|
|
|
81
|
+
(row["state"] || {}).each do |key, value|
|
|
82
|
+
key = key.to_sym
|
|
83
|
+
state[key] = value if keys.include?(key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|