phlex-reactive 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -0
- data/README.md +111 -3
- data/app/controllers/phlex/reactive/actions_controller.rb +49 -12
- data/app/javascript/phlex/reactive/reactive_controller.js +113 -17
- data/lib/generators/phlex/reactive/install/install_generator.rb +6 -6
- data/lib/phlex/reactive/component.rb +97 -19
- data/lib/phlex/reactive/engine.rb +18 -9
- data/lib/phlex/reactive/reply.rb +30 -3
- data/lib/phlex/reactive/response.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +107 -11
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +57 -5
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8811ee6a3c0f044fd7c9d16843a641296b33b8fcb5872f5bc8822257f837148e
|
|
4
|
+
data.tar.gz: 9398a1c088680e83b8bed3398342345ceb817703183df0985a1948e29df021cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b17a5d83f62e801d0f8673f824f929a82d1c7efc2d0aa2274034639ed94b2c8c4723f11484427f051c7be15d523a0b99cd11594bed0d701e4ff2e07604a1edd8
|
|
7
|
+
data.tar.gz: b5990726c0531bd2d6bc2da23df8bb2dbf5220ee702306c86f8094690da8c4a1752fa8ffb7949261050858256e5fd70854491603ac2306221c9f7bf2078f943c
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,150 @@ 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
|
+
- **Multipart path silently dropped an explicit nested-hash / array param (#39).**
|
|
76
|
+
When an action declared a `:file` param alongside an explicit nested (hash/array)
|
|
77
|
+
param, the nested param was dropped on the multipart path while the JSON path
|
|
78
|
+
handled it correctly — the two encodings were asymmetric. The client's
|
|
79
|
+
`#buildFormData` `JSON.stringify`'d a non-scalar param into one
|
|
80
|
+
`params[key]='<json>'` field, which the server received as an un-decodable
|
|
81
|
+
`String` leaf and dropped (nested hash → `{}`, array → key removed). Fixed
|
|
82
|
+
client-side: `#buildFormData` now bracket-expands a nested object/array into
|
|
83
|
+
`params[key][sub]` / `params[key][index][...]` fields (arrays use numeric
|
|
84
|
+
indices) — the same Rails-form shape the server's `expand_bracket_keys` /
|
|
85
|
+
`array_values` already parse, so a JSON body and a multipart body now coerce
|
|
86
|
+
identically. The server is unchanged. One intentional divergence: an empty
|
|
87
|
+
array/object as a whole param can't be carried by `FormData`, so the multipart
|
|
88
|
+
path omits the key (the action's keyword default applies) rather than sending an
|
|
89
|
+
explicit-clear `[]`/`{}`. Covered end-to-end: the bracketed multipart shape
|
|
90
|
+
coerces correctly (request specs), a `:file` alongside a nested param both
|
|
91
|
+
survive (request spec), and the FormData wire shape (bun unit tests).
|
|
92
|
+
|
|
93
|
+
- **`to_stream_token` emitted an EMPTY token, making non-self-rendering replies
|
|
94
|
+
add-once-only (cosmos#1939).** `Streamable#to_stream_token` guarded on
|
|
95
|
+
`respond_to?(:reactive_token)`, but `Component#reactive_token` is **private**, so
|
|
96
|
+
the guard was false for every component and the refresh stream carried
|
|
97
|
+
`data-reactive-token-value=""`. Any reply that opts out of the full-self replace
|
|
98
|
+
but relies on the token-only refresh — `reply.streams` (#30) and the new
|
|
99
|
+
`reply.append`/`reply.remove` (#35) — therefore rolled an empty token forward:
|
|
100
|
+
the first action worked, then the next dispatch from that root was rejected (the
|
|
101
|
+
stale/empty token fails verification) with no error. Fixed by checking private
|
|
102
|
+
methods (`respond_to?(:reactive_token, true)`); a bare Streamable (genuinely no
|
|
103
|
+
token) still skips correctly. The `reactive_collection` builders also now bind the
|
|
104
|
+
**container** as the `token_component`, so an add/remove rolls the list root's
|
|
105
|
+
token forward (the load-bearing part for repeated add/remove). Regression tests
|
|
106
|
+
assert the token is non-empty AND re-verifies, and that a second add using the
|
|
107
|
+
first reply's token succeeds.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- **The browser suite now runs under two real servers — Puma and Falcon.** The
|
|
112
|
+
`system` CI job runs as a matrix (`CAPYBARA_SERVER=puma` and `=falcon`), and a
|
|
113
|
+
new `rake spec:system_servers` task runs both locally, so a reactive round trip
|
|
114
|
+
is proven transport-agnostic across a sync (Puma, thread-pool) and an async
|
|
115
|
+
(Falcon, fiber-per-request) server. `webrick` is **removed** as a test server
|
|
116
|
+
(it isn't a real server) — `falcon` (with `protocol-rack`) replaces it as the
|
|
117
|
+
alternative, registered via `Capybara.register_server(:falcon)`. No production
|
|
118
|
+
dependency change; this is test infrastructure only.
|
|
119
|
+
|
|
120
|
+
- **CI now tests against Ruby 4.0** (added to the test matrix in `main.yml` and
|
|
121
|
+
the pre-release matrix in `release.yml`, alongside 3.2/3.3/3.4; the lint and
|
|
122
|
+
browser-system jobs also run on 4.0 now). Ruby 4.0 shipped December 2025 and is
|
|
123
|
+
the current stable line. The gem requires no code changes for 4.0 — its
|
|
124
|
+
dependencies and the patterns it uses (ObjectSpace::WeakMap, Data.define,
|
|
125
|
+
Thread-locals, the MessageVerifier path) are stable across 3.4 → 4.0, and it
|
|
126
|
+
directly requires none of the gems 4.0 moved from default to bundled.
|
|
127
|
+
`required_ruby_version` stays `>= 3.2.0` (no upper bound, already permits 4.0).
|
|
128
|
+
Verified empirically: the full unit/request/generator suite is green on Ruby
|
|
129
|
+
4.0.5 with zero deprecation or unbundled-gem warnings.
|
|
130
|
+
|
|
131
|
+
### Added
|
|
132
|
+
|
|
133
|
+
- **`reactive_collection` — add/remove-row lists in one declaration (issue #35).**
|
|
134
|
+
An add/remove-row list (line items, tags, comments, a notifications list) is one
|
|
135
|
+
of the most common reactive surfaces, and every one re-implements the same
|
|
136
|
+
orchestration by hand: append the row to the right container, remove it on
|
|
137
|
+
delete, keep a count badge in sync, and swap an empty-state in/out as the list
|
|
138
|
+
crosses 0↔1. `reactive_collection :items, item:, container:, count:, empty:,
|
|
139
|
+
size:` declares that contract **once** on the container component, so each action
|
|
140
|
+
is a single call: `reply.append(:items, model)` (row + count + empty-state
|
|
141
|
+
clear), `reply.prepend(:items, model)`, and `reply.remove(:items, model_or_id)`
|
|
142
|
+
(row + count + empty-state restore). The size badge and empty-state toggle are
|
|
143
|
+
driven by the declared `size:` resolver, **re-counted server-side after the
|
|
144
|
+
mutation** — so they're correct-by-construction (no off-by-one, no client-held
|
|
145
|
+
count, consistent between the first render and the deltas). `count:`/`empty:`/
|
|
146
|
+
`size:` are optional (omit them and only the row stream is emitted), and the new
|
|
147
|
+
builders compose with `.flash`/`.stream` like any other reply. Reply governs the
|
|
148
|
+
actor's HTTP response only; a cross-tab live list still broadcasts the row with
|
|
149
|
+
`broadcast_append_to(..., exclude: reactive_connection_id)`. New
|
|
150
|
+
`Phlex::Reactive::Component::CollectionDefinition`,
|
|
151
|
+
`reactive_collection`/`reactive_collection_def`/`reactive_collection?` macros,
|
|
152
|
+
and `Response.collection_append`/`collection_prepend`/`collection_remove` behind
|
|
153
|
+
the `reply.*` surface.
|
|
154
|
+
|
|
11
155
|
- **`reply.streams` — partial update with a token-only refresh (issue #30).**
|
|
12
156
|
`reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
|
|
13
157
|
no forced full-self replace — so an action can re-render only part of a
|
|
@@ -100,6 +244,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
100
244
|
trip still goes through the per-component queue so token threading holds.
|
|
101
245
|
Omitting `debounce:` keeps the immediate-dispatch default.
|
|
102
246
|
|
|
247
|
+
### Performance
|
|
248
|
+
|
|
249
|
+
- **Re-render is ~2× faster with ~half the allocations.** `render_component` now
|
|
250
|
+
renders through phlex-rails' lightweight `#render_in` against a memoized
|
|
251
|
+
off-request view context, instead of `ActionController.renderer.render` (which
|
|
252
|
+
dragged in ActionView's `TemplateRenderer`/`LookupContext`/log subscriber).
|
|
253
|
+
Measured same-machine before/after: `render_component` 6.99k→14.1k i/s (212→99
|
|
254
|
+
obj/call); `to_stream_replace` 4.60k→8.00k i/s (331→191 obj/call). HTML is
|
|
255
|
+
byte-identical and the full Rails helper set (`dom_id`/`url_for`/`t`/CSRF) still
|
|
256
|
+
works. The biggest win is on the **broadcast** path (N subscribers = N renders,
|
|
257
|
+
no HTTP to amortize against); at the full-request level the Rails stack + DB
|
|
258
|
+
dominate, so request throughput is roughly unchanged.
|
|
259
|
+
|
|
260
|
+
- **View context, Turbo `TagBuilder`, and flash builder are memoized** per
|
|
261
|
+
component class (the comment used to claim this; now it's true). Keyed on the
|
|
262
|
+
configured renderer's identity so swapping `Phlex::Reactive.renderer` rebuilds,
|
|
263
|
+
and reset on Rails code reload (`config.to_prepare`) so a reloaded controller
|
|
264
|
+
is never served stale.
|
|
265
|
+
|
|
266
|
+
- **Smaller per-render allocations on the token path.** `reactive_token`
|
|
267
|
+
precomputes its ivar symbols + state string-keys per class (14→11 obj/call,
|
|
268
|
+
state-backed), and `on(:action)` skips re-serializing an empty params hash
|
|
269
|
+
(6→5 obj/call). The bracket-key regex in param coercion is hoisted to a frozen
|
|
270
|
+
constant.
|
|
271
|
+
|
|
272
|
+
- **Client: the page-stable action path is resolved once per controller** instead
|
|
273
|
+
of a `querySelector` per dispatch. CSRF token and pgbus connection id stay live
|
|
274
|
+
(they can rotate).
|
|
275
|
+
|
|
276
|
+
- **Benchmark harness + CI report.** `rake bench` (micro: render/token/coerce) and
|
|
277
|
+
`rake bench:request` (end-to-end via derailed) measure the hot paths; a CI
|
|
278
|
+
`bench` job runs them on every PR and uploads the report as an artifact
|
|
279
|
+
(run-and-report, not a hard gate). See `docs/performance.md` and the `/perf`
|
|
280
|
+
command.
|
|
281
|
+
|
|
103
282
|
### Fixed
|
|
104
283
|
|
|
105
284
|
- **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
|
-
|
|
319
|
-
|
|
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? {
|
|
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? {
|
|
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(&
|
|
129
|
+
def transaction_wrapper(&)
|
|
130
130
|
if defined?(::ActiveRecord::Base)
|
|
131
|
-
::ActiveRecord::Base.transaction(&
|
|
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
|
-
|
|
172
|
+
case type
|
|
173
|
+
when Array
|
|
172
174
|
coerce_array(value, type.first)
|
|
173
|
-
|
|
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 {
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
334
|
+
unless klass.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
|
|
298
335
|
raise Phlex::Reactive::InvalidToken
|
|
299
336
|
end
|
|
300
337
|
|