phlex-reactive 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7232501319d17c45c8907195117f5af59d8b7a4ff9c8d5efb2c0c8248350041e
4
- data.tar.gz: 783cd0758a5a51cead67f6f1ea2c8bf23245f62e49a38ea0831328b68b9d0b2c
3
+ metadata.gz: 786d397de0be7e32424c73079906cdf8d32d0325727d06027025d888693754e6
4
+ data.tar.gz: b080f12ff8c8f4271066993548f9c951278fe2dacabf9b7d21c2113704f58da4
5
5
  SHA512:
6
- metadata.gz: a673a7253c99e7b6fccb5228014a47d2918cda2995de79afdb6545adb89a8af8ab99812569455bc7f206c12d7c7efb710ff90d628d767da012ba5deb690e5ab7
7
- data.tar.gz: cf68ac7ff60925c73aa0de543665b60d85d8254bada63a40c1786a6a3e7ee3bfceaad123c91daf2f7fec18de8aab04ab61d2ee165b5e0ad241b451ce60c24769
6
+ metadata.gz: 316b9b2094490dd5045d1362276a8f31ba4deee96df08e6614dc9db8f62a2ceb822f5a474d2204dcb65f7ed1bb27b3c99303f14a180a336c2d5bc3d21c3bf31a
7
+ data.tar.gz: f54ecfe74e977b3d4a399cd004071792a3a7245636d1c82f19441b0240fb2d24d32bb072a3ae863a9e04efcab5a31182560628237f72bf77ff73ac387592e4c4
data/CHANGELOG.md CHANGED
@@ -8,6 +8,171 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **File / multipart params in a reactive action (#34).** An action can now accept
12
+ an uploaded file: declare `params: { file: :file }` (or `[:file]` for multiple).
13
+ When the reactive root holds a populated `<input type="file">`, the client sends
14
+ the action as multipart `FormData` instead of JSON — `token` + `act` + scalar
15
+ params as fields, the file(s) appended — and the endpoint coerces `:file` to the
16
+ `ActionDispatch::Http::UploadedFile`, passed through untouched. A non-file value
17
+ sent to a `:file` param is dropped (the keyword default applies), consistent with
18
+ the #16 coercion rules — and for a `[:file]` array, a non-file *element* (a
19
+ forged/mixed payload) is rejected from the array too, so the internal coercion
20
+ sentinel never leaks to the action. A `<input type="file" multiple>` keeps its
21
+ array shape (`params[name][]`) even when the user picks exactly one file, so a
22
+ `[:file]` schema still coerces it. Token threading and the re-render/morph are
23
+ identical; only the request encoding changes when a file is present — so
24
+ attaching a document/receipt/image stays a reactive action instead of dropping
25
+ out to a bespoke controller + upload Stimulus controller. Covered end-to-end:
26
+ server coercion (request specs), the FormData wire shape (bun unit tests), and a
27
+ real browser upload under Puma + Falcon (system spec).
28
+
29
+ ### Changed
30
+
31
+ - **Linter: Standard → RuboCop.** The gem now lints with RuboCop (all new cops
32
+ enabled, `NewCops: enable`) instead of StandardRB, so the suite teaches and
33
+ enforces newer Ruby idioms that Standard leaves alone — chiefly the Ruby 3.4
34
+ `it` implicit single block parameter (`map { it.foo }`) via
35
+ `Style/ItBlockParameter: always`. The whole tree was autocorrected; the
36
+ lib/app changes were the `|x| x.foo` → `it.foo` rename plus hash-brace
37
+ spacing — behavior is unchanged and all specs are green. The gemspec and
38
+ Rakefile are excluded from `Style/ItBlockParameter` (their `do |spec| … end`
39
+ config blocks read better named); a nested Phlex content block and a scope
40
+ lambda carry inline disables where blanket `it` would collapse two parameters
41
+ or break a `where(room:)` kwarg shorthand; and blocks that fed a
42
+ keyword-argument shorthand (`Component.new(todo:)`) were rewritten to an
43
+ explicit `Component.new(todo: it)` so the `it` rename can't silently change
44
+ the keyword. Component-
45
+ aware relaxations (line length, `Lint/MissingSuper`, unused action params)
46
+ are scoped to `spec/dummy/app/components/**`. `bundle exec standardrb` is now
47
+ `bundle exec rubocop`; `rake`'s default runs `spec + rubocop`.
48
+ - **Added `rubocop-capybara` and `rubocop-thread_safety`.** Capybara lints the
49
+ browser specs (clean today — the suite already uses waiting matchers).
50
+ thread_safety stays on as a tripwire for unsafe shared mutable state on the
51
+ request/broadcast path; its `ClassInstanceVariable` /
52
+ `ClassAndModuleAttributes` cops are scoped off only in the three files whose
53
+ flagged class-level state is deliberate and audited — module-level config
54
+ (`lib/phlex/reactive.rb`, set once at boot), class-definition-time DSL
55
+ registries (`component.rb`), and the per-thread view-context cache's integer
56
+ generation counter (`streamable.rb`). An adversarial audit confirmed none are
57
+ request-path hazards; the lazy `||=` config defaults are idempotent. (A
58
+ follow-up may warm `verifier`/`renderer` at boot to remove even the
59
+ benign-by-idempotency first-call race — out of scope for this lint change.)
60
+
61
+ ### Removed
62
+
63
+ - **Dropped the `standard` development dependency** in favor of `rubocop`,
64
+ `rubocop-capybara`, `rubocop-performance`, `rubocop-rake`, `rubocop-rspec`,
65
+ and `rubocop-thread_safety`.
66
+
67
+ ### BREAKING
68
+
69
+ - **Minimum Ruby is now 3.4** (was 3.2). Enabling the `it` block parameter
70
+ requires Ruby 3.4, so `required_ruby_version` is `>= 3.4.0` and the CI matrix
71
+ drops 3.2 and 3.3. Stay on phlex-reactive 0.3.x if you need Ruby 3.2/3.3.
72
+
73
+ ### Fixed
74
+
75
+ - **0.4.0 regression: re-renders lost the `request`, crashing `form_authenticity_token`
76
+ and other request-dependent helpers (#42).** The 0.4.0 render-path perf rework
77
+ built the cached off-request view context from a bare
78
+ `ActionController::Base.new.view_context`, whose controller has `request == nil`.
79
+ Any reactive component whose `view_template` calls `form_authenticity_token`,
80
+ `protect_against_forgery?`, or a host-aware URL helper (all of which read
81
+ `request.env`) then raised `NoMethodError: undefined method 'env' for nil` on
82
+ **every** re-render — through the action endpoint AND on broadcasts — so any app
83
+ with a reactive component rendering a Rails CSRF form could not adopt 0.4.0. Fixed
84
+ by building the cached view context through a request-bound controller
85
+ (`Phlex::Reactive.request_bound_view_context`), replicating exactly what
86
+ `ActionController::Renderer#render` does to set up its mock request: an
87
+ `ActionDispatch::Request` whose host is derived from the routes'
88
+ `default_url_options`. The 0.4.0 `render_in` speedup is kept — the request is set
89
+ up once when the per-thread context is built (not per render), and steady-state
90
+ render throughput/allocations are unchanged (retained-per-render stays 0). Covered
91
+ at the request level (a reactive `save` re-rendering a CSRF form returns 200, not
92
+ 500), the broadcast level (the form component broadcasts without crashing), and the
93
+ unit level (a request is present on the cached context; host-aware `*_url` helpers
94
+ resolve when the renderer carries routes).
95
+
96
+ - **Multipart path silently dropped an explicit nested-hash / array param (#39).**
97
+ When an action declared a `:file` param alongside an explicit nested (hash/array)
98
+ param, the nested param was dropped on the multipart path while the JSON path
99
+ handled it correctly — the two encodings were asymmetric. The client's
100
+ `#buildFormData` `JSON.stringify`'d a non-scalar param into one
101
+ `params[key]='<json>'` field, which the server received as an un-decodable
102
+ `String` leaf and dropped (nested hash → `{}`, array → key removed). Fixed
103
+ client-side: `#buildFormData` now bracket-expands a nested object/array into
104
+ `params[key][sub]` / `params[key][index][...]` fields (arrays use numeric
105
+ indices) — the same Rails-form shape the server's `expand_bracket_keys` /
106
+ `array_values` already parse, so a JSON body and a multipart body now coerce
107
+ identically. The server is unchanged. One intentional divergence: an empty
108
+ array/object as a whole param can't be carried by `FormData`, so the multipart
109
+ path omits the key (the action's keyword default applies) rather than sending an
110
+ explicit-clear `[]`/`{}`. Covered end-to-end: the bracketed multipart shape
111
+ coerces correctly (request specs), a `:file` alongside a nested param both
112
+ survive (request spec), and the FormData wire shape (bun unit tests).
113
+
114
+ - **`to_stream_token` emitted an EMPTY token, making non-self-rendering replies
115
+ add-once-only (cosmos#1939).** `Streamable#to_stream_token` guarded on
116
+ `respond_to?(:reactive_token)`, but `Component#reactive_token` is **private**, so
117
+ the guard was false for every component and the refresh stream carried
118
+ `data-reactive-token-value=""`. Any reply that opts out of the full-self replace
119
+ but relies on the token-only refresh — `reply.streams` (#30) and the new
120
+ `reply.append`/`reply.remove` (#35) — therefore rolled an empty token forward:
121
+ the first action worked, then the next dispatch from that root was rejected (the
122
+ stale/empty token fails verification) with no error. Fixed by checking private
123
+ methods (`respond_to?(:reactive_token, true)`); a bare Streamable (genuinely no
124
+ token) still skips correctly. The `reactive_collection` builders also now bind the
125
+ **container** as the `token_component`, so an add/remove rolls the list root's
126
+ token forward (the load-bearing part for repeated add/remove). Regression tests
127
+ assert the token is non-empty AND re-verifies, and that a second add using the
128
+ first reply's token succeeds.
129
+
130
+ ### Changed
131
+
132
+ - **The browser suite now runs under two real servers — Puma and Falcon.** The
133
+ `system` CI job runs as a matrix (`CAPYBARA_SERVER=puma` and `=falcon`), and a
134
+ new `rake spec:system_servers` task runs both locally, so a reactive round trip
135
+ is proven transport-agnostic across a sync (Puma, thread-pool) and an async
136
+ (Falcon, fiber-per-request) server. `webrick` is **removed** as a test server
137
+ (it isn't a real server) — `falcon` (with `protocol-rack`) replaces it as the
138
+ alternative, registered via `Capybara.register_server(:falcon)`. No production
139
+ dependency change; this is test infrastructure only.
140
+
141
+ - **CI now tests against Ruby 4.0** (added to the test matrix in `main.yml` and
142
+ the pre-release matrix in `release.yml`, alongside 3.2/3.3/3.4; the lint and
143
+ browser-system jobs also run on 4.0 now). Ruby 4.0 shipped December 2025 and is
144
+ the current stable line. The gem requires no code changes for 4.0 — its
145
+ dependencies and the patterns it uses (ObjectSpace::WeakMap, Data.define,
146
+ Thread-locals, the MessageVerifier path) are stable across 3.4 → 4.0, and it
147
+ directly requires none of the gems 4.0 moved from default to bundled.
148
+ `required_ruby_version` stays `>= 3.2.0` (no upper bound, already permits 4.0).
149
+ Verified empirically: the full unit/request/generator suite is green on Ruby
150
+ 4.0.5 with zero deprecation or unbundled-gem warnings.
151
+
152
+ ### Added
153
+
154
+ - **`reactive_collection` — add/remove-row lists in one declaration (issue #35).**
155
+ An add/remove-row list (line items, tags, comments, a notifications list) is one
156
+ of the most common reactive surfaces, and every one re-implements the same
157
+ orchestration by hand: append the row to the right container, remove it on
158
+ delete, keep a count badge in sync, and swap an empty-state in/out as the list
159
+ crosses 0↔1. `reactive_collection :items, item:, container:, count:, empty:,
160
+ size:` declares that contract **once** on the container component, so each action
161
+ is a single call: `reply.append(:items, model)` (row + count + empty-state
162
+ clear), `reply.prepend(:items, model)`, and `reply.remove(:items, model_or_id)`
163
+ (row + count + empty-state restore). The size badge and empty-state toggle are
164
+ driven by the declared `size:` resolver, **re-counted server-side after the
165
+ mutation** — so they're correct-by-construction (no off-by-one, no client-held
166
+ count, consistent between the first render and the deltas). `count:`/`empty:`/
167
+ `size:` are optional (omit them and only the row stream is emitted), and the new
168
+ builders compose with `.flash`/`.stream` like any other reply. Reply governs the
169
+ actor's HTTP response only; a cross-tab live list still broadcasts the row with
170
+ `broadcast_append_to(..., exclude: reactive_connection_id)`. New
171
+ `Phlex::Reactive::Component::CollectionDefinition`,
172
+ `reactive_collection`/`reactive_collection_def`/`reactive_collection?` macros,
173
+ and `Response.collection_append`/`collection_prepend`/`collection_remove` behind
174
+ the `reply.*` surface.
175
+
11
176
  - **`reply.streams` — partial update with a token-only refresh (issue #30).**
12
177
  `reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
13
178
  no forced full-self replace — so an action can re-render only part of a
@@ -100,6 +265,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
100
265
  trip still goes through the per-component queue so token threading holds.
101
266
  Omitting `debounce:` keeps the immediate-dispatch default.
102
267
 
268
+ ### Performance
269
+
270
+ - **Re-render is ~2× faster with ~half the allocations.** `render_component` now
271
+ renders through phlex-rails' lightweight `#render_in` against a memoized
272
+ off-request view context, instead of `ActionController.renderer.render` (which
273
+ dragged in ActionView's `TemplateRenderer`/`LookupContext`/log subscriber).
274
+ Measured same-machine before/after: `render_component` 6.99k→14.1k i/s (212→99
275
+ obj/call); `to_stream_replace` 4.60k→8.00k i/s (331→191 obj/call). HTML is
276
+ byte-identical and the full Rails helper set (`dom_id`/`url_for`/`t`/CSRF) still
277
+ works. The biggest win is on the **broadcast** path (N subscribers = N renders,
278
+ no HTTP to amortize against); at the full-request level the Rails stack + DB
279
+ dominate, so request throughput is roughly unchanged.
280
+
281
+ - **View context, Turbo `TagBuilder`, and flash builder are memoized** per
282
+ component class (the comment used to claim this; now it's true). Keyed on the
283
+ configured renderer's identity so swapping `Phlex::Reactive.renderer` rebuilds,
284
+ and reset on Rails code reload (`config.to_prepare`) so a reloaded controller
285
+ is never served stale.
286
+
287
+ - **Smaller per-render allocations on the token path.** `reactive_token`
288
+ precomputes its ivar symbols + state string-keys per class (14→11 obj/call,
289
+ state-backed), and `on(:action)` skips re-serializing an empty params hash
290
+ (6→5 obj/call). The bracket-key regex in param coercion is hoisted to a frozen
291
+ constant.
292
+
293
+ - **Client: the page-stable action path is resolved once per controller** instead
294
+ of a `querySelector` per dispatch. CSRF token and pgbus connection id stay live
295
+ (they can rotate).
296
+
297
+ - **Benchmark harness + CI report.** `rake bench` (micro: render/token/coerce) and
298
+ `rake bench:request` (end-to-end via derailed) measure the hot paths; a CI
299
+ `bench` job runs them on every PR and uploads the report as an artifact
300
+ (run-and-report, not a hard gate). See `docs/performance.md` and the `/perf`
301
+ command.
302
+
103
303
  ### Fixed
104
304
 
105
305
  - **Model-scoped form fields feed a nested param (issue #21).** A Rails
data/README.md CHANGED
@@ -313,10 +313,52 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
313
313
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
314
314
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
315
315
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
316
+ | `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
316
317
  | `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
318
+ | `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
319
+
320
+ Param types: `:string` (default), `:integer`, `:float`, `:boolean`, `:file`.
321
+ Anything not in the schema is dropped before reaching your method.
322
+
323
+ **File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
324
+ an uploaded file in a reactive action — attach a document/receipt/image to the
325
+ record without dropping out to a bespoke controller. When the reactive root holds
326
+ a populated `<input type="file">`, the client sends the action as multipart
327
+ `FormData` (instead of JSON) — `token` + `act` as fields, scalar params as fields,
328
+ any nested/array params bracket-expanded into `params[key][sub]` /
329
+ `params[key][index]` fields (the same Rails-form shape, so a JSON body and a
330
+ multipart body coerce identically — #39), and the file(s) appended; the endpoint
331
+ coerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through
332
+ untouched. A non-file value sent to a `:file` param is dropped (the keyword
333
+ default applies — never a fabricated file). Token threading and the
334
+ re-render/morph are identical; only the request encoding changes when a file is
335
+ present.
317
336
 
318
- Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
319
- in the schema is dropped before reaching your method.
337
+ ```ruby
338
+ reactive_record :document
339
+ action :upload, params: { file: :file, caption: :string } # single (has_one_attached)
340
+ action :upload_pages, params: { pages: [:file] } # multiple (has_many_attached)
341
+
342
+ def upload(file: nil, caption: nil)
343
+ @document.file.attach(file) if file
344
+ @document.update!(title: caption) if caption.present?
345
+ end
346
+
347
+ def view_template
348
+ form(**on(:upload, event: "submit")) do
349
+ input(type: "file", name: "file")
350
+ input(name: "caption")
351
+ button(type: "submit") { "Upload" }
352
+ end
353
+ end
354
+ ```
355
+
356
+ > **One multipart caveat:** `FormData` can't carry an *empty* array or hash, so on
357
+ > the multipart (file-present) path an empty `[]`/`{}` param is **omitted** and the
358
+ > action's keyword default applies — it does **not** arrive as an explicit empty
359
+ > collection the way it does over JSON. If you rely on sending `tags: []` to clear
360
+ > a collection, send that action *without* a file (the JSON path). A non-empty
361
+ > nested/array param rides along fine next to a file.
320
362
 
321
363
  **Array & nested params.** Wrap a type in an array for an array param, or a hash
322
364
  schema in an array for Rails-style nested attributes — so one reactive action can
@@ -485,12 +527,77 @@ update only the targets you name) and refreshes the token via a tiny inert
485
527
  > (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
486
528
  > the preferred surface; treat `Response` as an internal detail.
487
529
  > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
488
- > **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
489
530
  > `html: @account.name` is safe even for user-supplied values. To emit intentional
490
531
  > markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
491
532
  > rendered and auto-escaped through the renderer — or an `html_safe` string for
492
533
  > raw HTML you control.
493
534
 
535
+ ### Reactive collections (add/remove rows + count + empty-state)
536
+
537
+ An add/remove-row list — line items, attachments, tags, comments, a
538
+ notifications list — is one of the most common reactive surfaces, and every one
539
+ re-implements the same orchestration by hand: append the row to the right
540
+ container, remove it on delete, keep a **count badge** in sync, and swap an
541
+ **empty-state** in/out as the list crosses 0↔1. `reactive_collection` declares
542
+ that contract **once** on the container so each action is a single call.
543
+
544
+ Declare the collection on the container component, then `reply.append` /
545
+ `reply.prepend` / `reply.remove` in the actions:
546
+
547
+ ```ruby
548
+ class NotificationsList < ApplicationComponent
549
+ include Phlex::Reactive::Streamable
550
+ include Phlex::Reactive::Component
551
+
552
+ reactive_collection :notifications,
553
+ item: NotificationRow, # the per-row Streamable component
554
+ container: "notifications", # the DOM id rows live in
555
+ count: "notifications-count", # optional companion id (the size badge)
556
+ empty: NotificationsEmpty, # optional empty-state component
557
+ size: -> { Todo.count } # resolves the live size (re-counted, never client state)
558
+
559
+ action :add, params: {title: :string}
560
+ action :dismiss, params: {id: :integer}
561
+
562
+ def add(title:)
563
+ todo = Todo.create!(title:)
564
+ reply.append(:notifications, todo) # append row + bump count + clear empty-state
565
+ end
566
+
567
+ def dismiss(id:)
568
+ Todo.find(id).destroy!
569
+ reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
570
+ end
571
+
572
+ # view_template renders the count, the container <ul>, and the empty-state on
573
+ # first paint — the same components the helper streams in/out on each delta.
574
+ end
575
+ ```
576
+
577
+ | Builder | Reply (one `Response`) |
578
+ |---|---|
579
+ | `reply.append(name, model)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
580
+ | `reply.prepend(name, model)` | as `append`, but the row goes to the top |
581
+ | `reply.remove(name, model)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
582
+
583
+ - **`size:` is the source of truth** — it's *re-counted* server-side after the
584
+ mutation, so the badge and the empty-state are correct-by-construction (no
585
+ off-by-one, no client-held count). `count:`, `empty:`, and `size:` are all
586
+ optional: omit them and only the row stream is emitted.
587
+ - **Repeated add/remove just works** — each reply rolls the **container's** signed
588
+ token forward (via the inert `reactive:token` refresh), so the second click from
589
+ the list root is accepted. Without this an add/remove list would be add-once-only
590
+ (correct on the first click, silently rejected after); the helper bakes the
591
+ refresh in so you never hit it.
592
+ - **`remove` takes the record or its `dom_id` string** — a just-destroyed
593
+ ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items, todo)`
594
+ works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
595
+ - **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
596
+ (other viewers see the row appear) keep broadcasting the row with
597
+ `NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)` —
598
+ `reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
599
+ not a replacement for the broadcast.
600
+
494
601
  ### Configuration (`config/initializers/phlex_reactive.rb`)
495
602
 
496
603
  ```ruby
@@ -661,6 +768,7 @@ See [docs/broadcasting.md](docs/broadcasting.md) and
661
768
  - [Broadcasting & live updates](docs/broadcasting.md)
662
769
  - [Transport: pgbus vs Action Cable](docs/transport-pgbus.md)
663
770
  - [Testing reactive components](docs/testing.md)
771
+ - [Performance & benchmarking](docs/performance.md)
664
772
  - Examples: [counter](docs/examples/counter.md) ·
665
773
  [chat](docs/examples/chat.md) · [todo list](docs/examples/todo_list.md) ·
666
774
  [inline edit](docs/examples/inline_edit.md) ·
@@ -101,7 +101,7 @@ module Phlex
101
101
  # replace when a hand-built `with(...)` stream omits it. Idempotent: a
102
102
  # Response.replace(self)/update(self) already carries the token, so we
103
103
  # don't double the self-render.
104
- if result.render_self? && streams.none? { |s| s.include?("data-reactive-token-value") }
104
+ if result.render_self? && streams.none? { it.include?("data-reactive-token-value") }
105
105
  streams = [component.to_stream_replace, *streams]
106
106
  end
107
107
  streams
@@ -115,7 +115,7 @@ module Phlex
115
115
  # us into skipping this component's refresh.
116
116
  def carries_token_for?(streams, component)
117
117
  target = %(target="#{ERB::Util.html_escape(component.id)}")
118
- streams.any? { |s| s.include?("data-reactive-token-value") && s.include?(target) }
118
+ streams.any? { it.include?("data-reactive-token-value") && it.include?(target) }
119
119
  end
120
120
 
121
121
  # A 200 turbo-stream carrying a namespaced custom action the client turns
@@ -126,9 +126,9 @@ module Phlex
126
126
  %(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
127
127
  end
128
128
 
129
- def transaction_wrapper(&block)
129
+ def transaction_wrapper(&)
130
130
  if defined?(::ActiveRecord::Base)
131
- ::ActiveRecord::Base.transaction(&block)
131
+ ::ActiveRecord::Base.transaction(&)
132
132
  else
133
133
  yield
134
134
  end
@@ -163,29 +163,60 @@ module Phlex
163
163
 
164
164
  # Coerce a value against a declared type. A type is one of:
165
165
  # * a scalar symbol (:string/:integer/:float/:boolean)
166
+ # * :file — a multipart upload (issue #34)
166
167
  # * a Hash schema ({ id: :integer, ... }) — nested object
167
168
  # * a one-element Array ([:integer] / [{ ... }]) — array of that
168
169
  # Arrays accept both a real JSON array and a Rails-style index hash
169
170
  # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
170
171
  def coerce(value, type)
171
- if type.is_a?(Array)
172
+ case type
173
+ when Array
172
174
  coerce_array(value, type.first)
173
- elsif type.is_a?(Hash)
175
+ when Hash
174
176
  coerce_hash(value, type)
177
+ when :file
178
+ coerce_file(value)
175
179
  else
176
180
  coerce_scalar(value, type)
177
181
  end
178
182
  end
179
183
 
184
+ # An uploaded file (issue #34) passes through UNTOUCHED — never .to_s'd,
185
+ # which would corrupt it into a string the action can't attach. Anything
186
+ # that isn't an uploaded file (a forged/malformed scalar, an empty input)
187
+ # returns DROP, so the method's keyword default applies — consistent with
188
+ # the #16 rule that a value that can't be coerced to its type is dropped,
189
+ # not fabricated. Duck-types on UploadedFile's interface (original_filename
190
+ # + a readable IO) rather than naming a class, so a Rack::Test upload, an
191
+ # ActionDispatch upload, and a Falcon multipart body all qualify.
192
+ def coerce_file(value)
193
+ uploaded_file?(value) ? value : DROP
194
+ end
195
+
196
+ def uploaded_file?(value)
197
+ value.respond_to?(:original_filename) && value.respond_to?(:read)
198
+ end
199
+
180
200
  # A real array (or Rails index hash) coerces element-wise. A malformed
181
201
  # present-but-non-array value returns DROP rather than [] — coercing a stray
182
202
  # scalar to an empty array would let a bad payload read as an explicit
183
203
  # "clear everything" on update!(declared_array:).
204
+ #
205
+ # An ELEMENT that coerces to DROP (e.g. a non-file in a [:file] array, a
206
+ # forged/mixed payload) is rejected from the result — the same rule
207
+ # coerce_hash applies to a dropped value, so the internal DROP sentinel
208
+ # never leaks into the action. A genuinely empty input array stays [] (an
209
+ # explicit empty collection), but an array whose every element drops
210
+ # returns DROP, so the keyword default applies rather than handing the
211
+ # action a surprise [].
184
212
  def coerce_array(value, element_type)
185
213
  values = array_values(value)
186
214
  return DROP if values.nil?
215
+ return [] if values.empty?
187
216
 
188
- values.map { |element| coerce(element, element_type) }
217
+ coerced = values.map { coerce(it, element_type) }
218
+ coerced.reject! { it.equal?(DROP) }
219
+ coerced.empty? ? DROP : coerced
189
220
  end
190
221
 
191
222
  # Keep declared keys only (drop undeclared — no mass assignment), recursing
@@ -219,9 +250,9 @@ module Phlex
219
250
  def array_values(value)
220
251
  return value.to_a if value.is_a?(Array)
221
252
 
222
- if value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
223
- to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
224
- end
253
+ return unless value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
254
+
255
+ to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
225
256
  end
226
257
 
227
258
  # Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
@@ -258,13 +289,19 @@ module Phlex
258
289
  end
259
290
  end
260
291
 
292
+ # Matches each bracket segment in "items_attributes][0][qty]" — the part
293
+ # after the first "[". Hoisted to a frozen constant so coercing a bracketed
294
+ # key doesn't recompile the pattern per key on every request.
295
+ BRACKET_SEGMENT = /[^\[\]]+/
296
+ private_constant :BRACKET_SEGMENT
297
+
261
298
  # "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
262
299
  # "0", "qty"]. A key with no brackets is a single-element path.
263
300
  def bracket_path(key)
264
301
  return [key] unless key.include?("[")
265
302
 
266
303
  head, rest = key.split("[", 2)
267
- [head, *rest.scan(/[^\[\]]+/)]
304
+ [head, *rest.scan(BRACKET_SEGMENT)]
268
305
  end
269
306
 
270
307
  # Walk/create nested hashes along `path`, then merge `value` at the leaf so
@@ -294,7 +331,7 @@ module Phlex
294
331
  # already gates this; defense in depth against constant injection.
295
332
  def resolve_component(name)
296
333
  klass = name.to_s.safe_constantize
297
- unless klass&.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
334
+ unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
298
335
  raise Phlex::Reactive::InvalidToken
299
336
  end
300
337