phlex-reactive 0.4.7 → 0.9.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +970 -0
  3. data/README.md +1134 -49
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +93 -0
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +37 -348
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +36 -7
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +20 -1
@@ -17,14 +17,15 @@ module Phlex
17
17
  # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
18
18
  # Response.replace(self).stream(Totals.update(@order)) # multi-stream
19
19
  class Response
20
- attr_reader :streams, :redirect_url, :token_component
20
+ attr_reader :streams, :redirect_url, :token_component, :subject_component
21
21
 
22
22
  class << self
23
23
  # Re-render the component in place (explicit form of today's default).
24
24
  # `morph: true` morphs the subtree (preserves the focused input + caret)
25
25
  # instead of an outerHTML swap — see .morph (issue #28).
26
26
  def replace(component, morph: false)
27
- new(streams: [morph ? component.to_stream_morph : component.to_stream_replace])
27
+ new(streams: [morph ? component.to_stream_morph : component.to_stream_replace],
28
+ subject_component: component)
28
29
  end
29
30
 
30
31
  # Re-render the component in place via Idiomorph (issue #28). Emits
@@ -33,10 +34,14 @@ module Phlex
33
34
  # per-field reactive editing (a "spreadsheet" grid where a debounced save
34
35
  # fires while the user is still typing/tabbing). The morphed root still
35
36
  # carries the fresh signed token, so the next action verifies.
36
- def morph(component) = new(streams: [component.to_stream_morph])
37
+ def morph(component) = new(streams: [component.to_stream_morph], subject_component: component)
37
38
 
38
- # Morph only inner HTML (preserves the root element + its token attr).
39
- def update(component) = new(streams: [component.to_stream_update])
39
+ # Update only inner HTML (preserves the root element + its token attr).
40
+ # `morph: true` morphs the inner HTML in place (issue #113) instead of
41
+ # replacing it, so a cross-tab update keeps a peer's focus/caret.
42
+ def update(component, morph: false)
43
+ new(streams: [component.to_stream_update(morph:)], subject_component: component)
44
+ end
40
45
 
41
46
  # Remove the component's element from the DOM. Uses the instance
42
47
  # to_stream_remove (the component already knows its own #id — no
@@ -130,7 +135,7 @@ module Phlex
130
135
  # or an already-built dom-id string (e.g. the value the row used as #id).
131
136
  def collection_row_remove(definition, model)
132
137
  if model.is_a?(String)
133
- Phlex::Reactive.flash_builder.remove(model)
138
+ Phlex::Reactive.stream_builder.remove(model)
134
139
  else
135
140
  definition.item.remove(model)
136
141
  end
@@ -167,18 +172,111 @@ module Phlex
167
172
 
168
173
  # Build a flash turbo-stream that appends `content` into a host-app
169
174
  # container. `content` is a Phlex component instance (rendered through
170
- # the configured renderer so t()/url_for work) or a ready HTML string —
171
- # supplied by the caller because the render context is off-request
172
- # (there is no Rails `flash`).
173
- def flash_stream(_level, content, target:)
174
- Phlex::Reactive.flash_builder.append(target, html: render_html(content))
175
+ # the configured renderer so t()/url_for work) or a String supplied
176
+ # by the caller because the render context is off-request (there is no
177
+ # Rails `flash`). The LEVEL reaches the wire (issue #77): string
178
+ # content gets a level-carrying wrapper (or the configured
179
+ # Phlex::Reactive.flash_component); component content renders VERBATIM
180
+ # — the caller owns the markup, no wrapper, no double-wrapping.
181
+ def flash_stream(level, content, target:, dismiss_after: nil)
182
+ Phlex::Reactive.stream_builder.append(target, html: flash_html(level, content, dismiss_after))
183
+ end
184
+
185
+ # Resolve flash `content` to HTML, carrying the level (issue #77):
186
+ # * a Phlex component instance — rendered verbatim, exactly as before
187
+ # (byte-identical; it also bypasses flash_component).
188
+ # * a String with Phlex::Reactive.flash_component configured — the
189
+ # app's component is instantiated new(level:, content:) and
190
+ # rendered through the existing render_html path.
191
+ # * a String otherwise — the default level-carrying wrapper below.
192
+ def flash_html(level, content, dismiss_after = nil)
193
+ # Verbatim Phlex component content owns its own markup (and lifecycle),
194
+ # so dismiss_after has nowhere to hang — it wraps only string content.
195
+ return render_html(content) if content.is_a?(::Phlex::SGML)
196
+
197
+ component_class = Phlex::Reactive.flash_component
198
+ if component_class
199
+ html = render_html(component_class.new(level:, content:))
200
+ return dismiss_after ? wrap_dismiss(html, dismiss_after) : html
201
+ end
202
+
203
+ default_flash_html(level, content, dismiss_after)
204
+ end
205
+
206
+ # The default string-flash wrapper:
207
+ # <div class="reactive-flash reactive-flash--{level}"
208
+ # data-reactive-flash-level="{level}">{content}</div>
209
+ # The level is unconditionally HTML-escaped (it lands in a class name
210
+ # and a data attribute). The content keeps the exact pre-#77 injection
211
+ # contract, now applied INSIDE the wrapper: ERB::Util.html_escape
212
+ # escapes a plain String but passes an html_safe String verbatim —
213
+ # the same behavior Turbo's TagBuilder gave the unwrapped string, so
214
+ # a model value still can't inject markup while intentional raw HTML
215
+ # (html_safe) still renders. The wrapper is html_safe by construction
216
+ # (both interpolations are escaped above), so the TagBuilder emits it
217
+ # as-is.
218
+ def default_flash_html(level, content, dismiss_after = nil)
219
+ level_attr = CGI.escapeHTML(level.to_s)
220
+ body = ERB::Util.html_escape(content.to_s)
221
+
222
+ # html_safe is safe by construction: every interpolated piece is escaped
223
+ # (dismiss_attr coerces to an integer, so it can't inject an attribute).
224
+ %(<div class="reactive-flash reactive-flash--#{level_attr}" \
225
+ data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}</div>).html_safe
226
+ end
227
+
228
+ # The self-dismiss attribute, or "" when off. The value is forced through
229
+ # to_i so a String/float (or an injection attempt) becomes a plain integer
230
+ # — the client's document-level handler reads it as a timeout in ms.
231
+ def dismiss_attr(dismiss_after)
232
+ return "" if dismiss_after.nil?
233
+
234
+ %( data-reactive-dismiss-after="#{dismiss_after.to_i}")
235
+ end
236
+
237
+ # Wrap already-rendered flash HTML (the flash_component path) in a
238
+ # dismiss-bearing div. SafeBuffer#to_s returns SELF, so concatenate the
239
+ # html_safe pieces (never .to_s + raw) to keep the buffer safe.
240
+ def wrap_dismiss(html, dismiss_after)
241
+ (%(<div#{dismiss_attr(dismiss_after)}>).html_safe + html.html_safe + "</div>".html_safe)
175
242
  end
176
243
 
177
244
  # Build a turbo-stream that updates an arbitrary target id with `content`
178
245
  # (a Phlex component instance or an HTML string). Used by #also_update to
179
246
  # re-render a companion element that isn't itself a Streamable component.
180
247
  def update_stream(target, content)
181
- Phlex::Reactive.flash_builder.update(target, html: render_html(content))
248
+ Phlex::Reactive.stream_builder.update(target, html: render_html(content))
249
+ end
250
+
251
+ # Build a `reactive:js` turbo-stream carrying server-pushed client DOM
252
+ # ops (issue #97). `ops` is a Phlex::Reactive::JS chain or a raw
253
+ # [[op, args], ...] array; `target` (an element id or nil) scopes op
254
+ # resolution on the client (nil → document-scoped). The ops JSON is
255
+ # HTML-escaped into the attribute exactly like to_stream_token — a raw
256
+ # interpolation would be an injection vector. The client's registered
257
+ # reactive:js handler runs the ops through the SAME whitelist interpreter
258
+ # as on_client (default-deny), so an unknown op is warn-and-skipped there.
259
+ def js_stream(ops, target: nil)
260
+ json = js_ops_json(ops)
261
+ target_attr = target ? %( target="#{ERB::Util.html_escape(target)}") : ""
262
+ %(<turbo-stream action="reactive:js"#{target_attr} \
263
+ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
264
+ end
265
+
266
+ # Normalize `ops` to its JSON wire form, rejecting an empty chain — a
267
+ # reactive:js stream with no ops is a dead stream (a mistake at the call
268
+ # site), so fail loudly rather than emit an inert tag.
269
+ def js_ops_json(ops)
270
+ if ops.is_a?(Phlex::Reactive::JS)
271
+ raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if ops.empty?
272
+
273
+ ops.to_json
274
+ else
275
+ list = Array(ops)
276
+ raise ArgumentError, "js(...) got no ops — a dead reactive:js stream" if list.empty?
277
+
278
+ list.to_json
279
+ end
182
280
  end
183
281
 
184
282
  # Resolve `content` to the HTML for a turbo-stream's `html:`. Two forms,
@@ -204,11 +302,19 @@ module Phlex
204
302
  # OUT of the full-self replace but still needs the token refreshed. The
205
303
  # endpoint appends this component's tiny to_stream_token instead, so the
206
304
  # token rolls forward without re-rendering (and clobbering) the children.
207
- def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
305
+ #
306
+ # subject_component: the component a self-targeting builder (replace/morph/
307
+ # update) re-renders (issue #97). Distinct from token_component so it does
308
+ # NOT trip refresh_token? — it exists only to default #js's target to the
309
+ # bound component's id, so reply.morph.js(js.focus("@root")) scopes to the
310
+ # morphed root without the caller repeating the id.
311
+ def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil,
312
+ subject_component: nil)
208
313
  @streams = streams.freeze
209
314
  @redirect_url = redirect_url
210
315
  @render_self = render_self
211
316
  @token_component = token_component
317
+ @subject_component = subject_component
212
318
  freeze
213
319
  end
214
320
 
@@ -219,14 +325,38 @@ module Phlex
219
325
  streams: @streams + more.flatten,
220
326
  redirect_url: @redirect_url,
221
327
  render_self: @render_self,
222
- token_component: @token_component
328
+ token_component: @token_component,
329
+ subject_component: @subject_component
223
330
  )
224
331
  end
225
332
 
333
+ # Chain a `reactive:js` op stream — server-pushed client DOM ops (issue
334
+ # #97) — onto this reply, so a focus/dispatch/class toggle runs on the
335
+ # client after the render applies:
336
+ #
337
+ # reply.morph.js(js.focus("[name=next]").dispatch("app:saved"))
338
+ #
339
+ # `ops` is a Phlex::Reactive::JS chain (or a raw [[op, args], ...] array).
340
+ # `target` (an element id) scopes op resolution on the client; it defaults
341
+ # to the bound component's id (subject_component/token_component) so
342
+ # self-scoped ops (@root, a component-relative selector) just work, and is
343
+ # nil (document-scoped) for a subject-free reply.with. Returns a NEW
344
+ # Response (immutable) — the op stream rides the same stream() plumbing, so
345
+ # the endpoint emits it LAST (after all render streams) and a focus op sees
346
+ # the post-render DOM.
347
+ def js(ops, target: :__default)
348
+ resolved = target == :__default ? default_js_target : target
349
+ stream(self.class.js_stream(ops, target: resolved))
350
+ end
351
+
226
352
  # Append a flash turbo-stream into a host-app container (default
227
353
  # <div id="flash">, configurable via Phlex::Reactive.flash_target).
228
- def flash(level, content, target: Phlex::Reactive.flash_target)
229
- stream(self.class.flash_stream(level, content, target:))
354
+ # `dismiss_after:` (ms) makes the flash self-remove after the timeout — a
355
+ # document-level client handler removes any [data-reactive-dismiss-after]
356
+ # container, so it works for reply AND broadcast flashes (issue #100). It
357
+ # wraps only STRING content; a verbatim Phlex component owns its lifecycle.
358
+ def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
359
+ stream(self.class.flash_stream(level, content, target:, dismiss_after:))
230
360
  end
231
361
 
232
362
  # Also re-render a COMPANION element alongside self — a page heading, a
@@ -259,6 +389,16 @@ module Phlex
259
389
  # but still needs the token rolled forward — the endpoint appends the bound
260
390
  # component's tiny token-only stream (issue #30).
261
391
  def refresh_token? = !@token_component.nil?
392
+
393
+ private
394
+
395
+ # The default `target` for #js: the bound component's id when this reply is
396
+ # component-scoped (replace/morph/update set subject_component; .streams and
397
+ # collections set token_component), else nil — a subject-free reply.with is
398
+ # document-scoped unless the caller passes target: explicitly.
399
+ def default_js_target
400
+ (@subject_component || @token_component)&.id
401
+ end
262
402
  end
263
403
  end
264
404
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # A Turbo Stream that IS an html_safe String — it drops into
6
+ # `render turbo_stream:`, ERB/Phlex interpolation, and Turbo test-helper
7
+ # substring asserts unchanged — but ALSO carries the structural facts the
8
+ # action endpoint needs: `rx_action`, `rx_target`, `rx_renders_root?`, and a
9
+ # token flag computed ONCE from ground truth (the html bytes).
10
+ #
11
+ # WHY (issue #114): the endpoint used to reverse-engineer turbo-stream
12
+ # SEMANTICS out of MARKUP — substring-scanning every stream for
13
+ # `data-reactive-token-value` and regexing the opening tag's `action="..."`
14
+ # against a self-render allowlist. The gem was parsing strings it just built,
15
+ # and every new stream shape needed a new patch to the heuristics. A Stream
16
+ # knows its action, target, and token-ness at CONSTRUCTION, so the endpoint
17
+ # reads fields instead of scanning markup.
18
+ #
19
+ # Subclasses ActiveSupport::SafeBuffer deliberately. The sole thing
20
+ # `render turbo_stream: [s1, s2]` does to each element is `plain_string << s`
21
+ # (verified against actionpack 8.1.3 / turbo-rails 2.0.23: the renderer
22
+ # returns the array unchanged, Response#body= stores it, and Buffer#body does
23
+ # `buf = +""; each { |chunk| buf << chunk }`). `String#<<` needs `#to_str`,
24
+ # which a SafeBuffer subclass inherits — so the ivars ride to the wire and
25
+ # the bytes are byte-identical to today's TagBuilder output. Being IS-A-String
26
+ # also satisfies every public-API interop requirement (clause 2) for free.
27
+ #
28
+ # Metadata-loss is a FEATURE, not a bug. `dup`/`+` keep the class + ivars, so
29
+ # they stay on the structural path with accurate fields (the bytes are
30
+ # unchanged). `gsub`/interpolation/`*` reshape the bytes and return a plain
31
+ # String/SafeBuffer — precisely when the object is no longer a
32
+ # structurally-known stream and SHOULD be treated as opaque. The endpoint's
33
+ # `is_a?(Stream) && rx_action` guard turns every such loss into the SAFE
34
+ # legacy regex path. INVARIANT: never `+`/`gsub`/`<<` a built Stream and keep
35
+ # using it as a Stream — re-`wrap` the result to recompute the flags.
36
+ class Stream < ActiveSupport::SafeBuffer
37
+ # The attribute the client's #extractToken reads. Its PRESENCE (by name) is
38
+ # what "carries a token" means — the same contract the old substring scan
39
+ # used, so an accidentally-empty token still flags true (not a regression;
40
+ # the real cosmos#1939 guard lives upstream in #to_stream_token).
41
+ TOKEN_ATTR = "data-reactive-token-value"
42
+
43
+ # Actions whose OPENING tag re-renders the component's OWN root, so the
44
+ # root's fresh token rolls the signed identity forward. `append`/`prepend`
45
+ # insert CHILDREN and are deliberately excluded (issue #44): a child row's
46
+ # token embedded in an append `<template>` must NEVER count as the
47
+ # container's refresh. `reactive:token` is our inert token-only refresh.
48
+ SELF_RENDER_ACTIONS = %w[replace update reactive:token].freeze
49
+
50
+ class << self
51
+ # The single construction seam every builder routes its already-html_safe
52
+ # output through. `html` MUST already be html_safe (a TagBuilder result or
53
+ # an explicitly `.html_safe` string) — we do NOT escape, so the wire stays
54
+ # byte-identical. `renders_root` is set STRUCTURALLY by the builder that
55
+ # knows its own semantics; `carries_token` is GROUND TRUTH.
56
+ def wrap(html, action:, target:, renders_root:)
57
+ new(html.to_s, action: action, target: target, renders_root: renders_root)
58
+ end
59
+ end
60
+
61
+ def initialize(html = "", action: nil, target: nil, renders_root: false)
62
+ super(html) # SafeBuffer copies the bytes verbatim — byte-identical wire.
63
+ @rx_action = action&.to_s
64
+ @rx_target = target&.to_s
65
+ @rx_renders_root = renders_root ? true : false
66
+ # ONE O(bytes) scan at BUILD time — the ground-truth token flag. A
67
+ # `with(...)` raw string that omits the token and a replace whose render
68
+ # happened to include one both answer honestly; the flag is NEVER
69
+ # inferred from `action` (that would reintroduce cosmos#1939).
70
+ @rx_carries_token = include?(TOKEN_ATTR)
71
+ freeze # an immutable value object
72
+ end
73
+
74
+ attr_reader :rx_action, :rx_target
75
+
76
+ def rx_renders_root? = @rx_renders_root
77
+ def rx_carries_token? = @rx_carries_token
78
+
79
+ # Does THIS stream ALREADY refresh `component_target`'s signed token by
80
+ # re-rendering its own root? Purely structural — replaces the two-method
81
+ # opening-tag regex (carries_token_for? + self_render_stream_for?). All must
82
+ # hold: it carries a token AND re-renders the root AND targets THIS
83
+ # component AND the action is a self-render action.
84
+ # * A sibling replace (different target) → false, so ours still refreshes
85
+ # (issue #30).
86
+ # * An appended child row (renders_root false) → false, so the container's
87
+ # token still refreshes even though the row embeds its own (issue #44).
88
+ # `component_target` is the RAW dom id (never the escaped `target="..."`
89
+ # attribute); escaping happens only in the emitted HTML.
90
+ def rx_refreshes_token_for?(component_target)
91
+ @rx_carries_token &&
92
+ @rx_renders_root &&
93
+ @rx_target == component_target.to_s &&
94
+ SELF_RENDER_ACTIONS.include?(@rx_action)
95
+ end
96
+ end
97
+ end
98
+ end