phlex-reactive 0.12.4 → 0.12.6

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: 735a21537bb98a3b89ba46f21fe40630ee413191e5509db2fe7895172e6b92b7
4
- data.tar.gz: 2110972a68b3ff0c336255afe15674167d9012201ebe80479a5c84fff922e682
3
+ metadata.gz: 7a9ece1128621af6ad4b9b8e249c1005bcfd063e0aa70235800292f156ac4419
4
+ data.tar.gz: f8613e2ec77fb55d1f9b5b0b30719382ef5e52c2a34d1ff6bcc29f9a017bafc7
5
5
  SHA512:
6
- metadata.gz: 7f6c98822ac5eabbcfdd39b6bdbbbd50176e6d4d5735b5eb0189321ac26e0b559ccc7f8ec60c9394492e23e53eb7e77b79c89c0298f1c962b2115961f8cbfc48
7
- data.tar.gz: 4b7dc6d779d5f250592387b3cb9615b4d9d37a5994c0b03e3b83ad1e740251e9875b06735b79a02e304ba0fe53b659220950c47906d0805d0128e81b84231966
6
+ metadata.gz: 19130860688333ec585f3da94ff25be2f8fc8c3fd103c023c98d4ac528921d7b02a7362448b864f81b435d35fc8946e0a3be7ba41f70f57751b115ed09b9a394
7
+ data.tar.gz: a6efc22b0270eb3e1f4cc7732cc1394acd40dd80de81b12a1dd792df9d2e3952bf5070872e21884cdcc9a5c91211791e74fc31e81f2f25cc1116587446a636c3
data/CHANGELOG.md CHANGED
@@ -8,6 +8,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **`reactive_persist` — client-only localStorage drafts (#239).** "Don't make
12
+ me start over": spread `reactive_persist(key:, ttl: 7.days)` on a root and
13
+ the generic controller keeps a `localStorage` draft of every **owned**
14
+ control — a debounced write on `input`, immediate on `change`, flushed on
15
+ disconnect — and restores it on the next connect, **first** among the
16
+ client bindings so `reactive_show` / `reactive_on_complete` (armed, never
17
+ fired) / `reactive_filter` / a compute root read the restored values on
18
+ first paint with no synthetic events. A draft lands only in controls the
19
+ server rendered blank (`restore: :always` lets it win); a morph is never
20
+ re-restored. Cleared by a successful `turbo:submit-end` of the containing
21
+ form, `ttl` expiry, or the new actor-only `js.persist_clear` op;
22
+ `js.persist_state(step: 2)` merges a flat state bag into the draft (restored
23
+ as `data-reactive-persist-state` + the `reactive:persist-restored` event).
24
+ `hidden`/`file`/`password`/`submit` controls, `reactive_persist_skip`
25
+ markers, nested roots and rich-text editors are never persisted; `fields:`
26
+ narrows the set. Storage failures are silent (a `console.info` under
27
+ `Phlex::Reactive.debug`). Works from `ClientBindings` (no token, no POST).
28
+
29
+ - **Dev-mode warning when a client op resolves zero targets (#237).** A client
30
+ op that matches nothing is indistinguishable from a working no-op, and the
31
+ three documented scoping traps — the nested-root ownership filter, a selector
32
+ that matches the component's own root (root-scoped resolution never includes
33
+ the root itself), and the op stream's default target scope — all present
34
+ exactly that way. Now `verbose_errors` (on by default in dev/test) reaches
35
+ the client as `data-reactive-verbose="true"` on reactive roots and on
36
+ server-emitted `reactive:js` streams, and under that gate (or full
37
+ `debug` mode) the interpreter `console.warn`s once per unique
38
+ (op, selector, scope) naming the op, the selector, and the scoping root —
39
+ plus the documented escape (`to: :root` / `global: true`) as a hint when the
40
+ element exists in the DOM but sits outside the op's scope. Covers `on_client`
41
+ ops, reducer `$ops`, `reply…js(...)` / `broadcast_js_to` streams (including a
42
+ stream whose `target` root id has left the DOM), and busy/optimistic hint
43
+ targets. Production (flag off) renders no attribute and stays byte-identical
44
+ and silent; resolution behavior itself is unchanged everywhere.
45
+
11
46
  - **`js.paste_into(selector)` — the clipboard-source trigger (#228).** A field
12
47
  whose real `<input>` is visually hidden (an OTP cell UI painted by a
13
48
  `reactive_compute` reducer) has no mouse-reachable paste path: right-click →
@@ -315,6 +350,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
315
350
 
316
351
  ### Fixed
317
352
 
353
+ - **`Component::Action` renamed `ActionDefinition` — it shadowed a host kit
354
+ component named `Action` under dev autoloading (#233).** The mixin sits in
355
+ every reactive component's ancestry, so its bare `Action` constant satisfied
356
+ Phlex::Kit's LazyLoader `const_get` check with the wrong class: the kit's
357
+ real `Action` component never autoloaded and the call fell through to
358
+ `NoMethodError` — intermittently (any unrelated load of the kit class masks
359
+ it until the next reload) and only under lazy autoloading, so an
360
+ eager-loaded test suite stays green. The rename matches its siblings
361
+ (`ComputeDefinition`, `OnCompleteDefinition`, `CollectionDefinition`) and
362
+ removes the constant entirely — **deliberately no alias**, since any
363
+ `Action` constant in the ancestry re-creates the shadowing. Breaking only
364
+ for code referencing the internal
365
+ `Phlex::Reactive::Component::Action` Data class directly; the `action :name`
366
+ DSL and `reactive_actions` registry are unchanged. A regression spec guards
367
+ the whole component ancestry against `:Action` coming back.
368
+
369
+ - **Multipart path corrupted/dropped fields with Rails-bracketed names (#231).**
370
+ `#buildFormData` wrapped every collected field/file name verbatim as
371
+ `params[<name>]`, so `blog_post[summary]` went out as the Rack-unparseable
372
+ `params[blog_post[summary]]` — the scalar arrived as `{"]" => value}` (and a
373
+ writer stringified that hash into the record, with a 200), and a `:file` under
374
+ a bracketed name (`blog_post[image]`) never reached its schema key (silent
375
+ drop). It only fired when a file input was populated — the JSON path expands
376
+ the same names server-side — which is why flat-named forms never caught it.
377
+ The client now bracket-expands the field **name** into wire segments
378
+ (`params[blog_post][summary]`, `params[blog_post][image]`, and
379
+ `params[photos][]` for a `multiple` picker named `photos[]`), mirroring the
380
+ server's `bracket_path` exactly, so a multipart body and a JSON body coerce
381
+ identically for the same fields. Flat names are byte-identical to before.
382
+
383
+ - **Reply-rendered components now generate absolute URLs with the REQUESTING
384
+ host, not the process default (#232).** Actor replies (`reply.replace`,
385
+ `reply.streams(...)`, the implicit replace) render through the memoized
386
+ off-request view context, whose `url_options` were process defaults — so any
387
+ absolute URL helper (`image_tag` on an Active Storage attachment being the
388
+ everyday case) rendered the wrong host on a multi-host app: a broken image
389
+ after every save, healed by the next full page load. The action endpoint now
390
+ threads the request's protocol/host/port into the reply render (the
391
+ `ActiveStorage::SetCurrent` move) via a thread-local the memoized context
392
+ merges per call — the context is never rebuilt, so the render memoization
393
+ win is untouched (allocations byte-identical, throughput flat within noise).
394
+ The defer **pull** endpoint gets the same treatment (it is an actor request).
395
+ **Broadcasts are unchanged by design** — there is no request and subscribers
396
+ can be on different hosts, so a broadcast fired *inside* an action explicitly
397
+ clears the actor's url_options around its render; "URLs in broadcast-rendered
398
+ components must be host-relative" is now documented in the Broadcasting guide.
399
+
318
400
  - **`rake bench` ran the removed `broadcast_replace_to` API and exited 1.**
319
401
  `benchmark/micro/broadcast.rb` migrated to the #185 spellings
320
402
  (`broadcast_to(*key, replace:)` / `broadcast_to(each:, replace:)`); the
data/README.md CHANGED
@@ -385,11 +385,13 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
385
385
  | `busy_on(:save)` | Mark any element so it carries `data-reactive-busy` **only while `save` is in flight** — a spinner styled with pure CSS, zero Ruby. See [Loading states](#declarative-loading-states-loading--disable_with). |
386
386
  | `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
387
387
  | `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
388
- | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), and `paste_into` (read the clipboard into a field, gesture-gated) — chainable. |
388
+ | `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), `dispatch`, `submit` (requestSubmit the target's own form), `paste_into` (read the clipboard into a field, gesture-gated), and `persist_state`/`persist_clear` (the `reactive_persist` draft) — chainable. |
389
389
  | `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
390
390
  | `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
391
391
  | `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
392
  | `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
393
+ | `reactive_persist(key:, ttl: 7.days)` | **Client-only drafts**: spread on the **root** (once) and the generic controller keeps a `localStorage` draft of every **owned** control — debounced write on `input`, immediate on `change`, flushed on disconnect, restored into **blank** controls on the next connect (`restore: :always` lets the draft win), cleared by a successful Turbo submit / `ttl` / `js.persist_clear`. Never hidden/file/password; `reactive_persist_skip` opts a control out; `fields:` narrows. See [Client-only drafts](#client-only-drafts-reactive_persist). |
394
+ | `js.persist_state(step: 2)` / `js.persist_clear` | The draft ops (actor-only): merge a flat state bag into the draft (restored as `data-reactive-persist-state` + the `reactive:persist-restored` event) / forget the draft. |
393
395
  | `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
394
396
  | `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
395
397
  | `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
@@ -458,7 +460,10 @@ a populated `<input type="file">`, the client sends the action as multipart
458
460
  `FormData` (instead of JSON) — `token` + `act` as fields, scalar params as fields,
459
461
  any nested/array params bracket-expanded into `params[key][sub]` /
460
462
  `params[key][index]` fields (the same Rails-form shape, so a JSON body and a
461
- multipart body coerce identically — #39), and the file(s) appended; the endpoint
463
+ multipart body coerce identically — #39), and the file(s) appended. A
464
+ Rails-bracketed field **name** (`blog_post[summary]`, `blog_post[image]`)
465
+ expands the same way — `params[blog_post][summary]`, never the unparseable
466
+ `params[blog_post[summary]]` (#231). The endpoint
462
467
  coerces `:file` to the `ActionDispatch::Http::UploadedFile`, passed through
463
468
  untouched. A non-file value sent to a `:file` param is dropped (the keyword
464
469
  default applies — never a fabricated file). Token threading and the
@@ -822,6 +827,14 @@ field collection); `:root` targets the root element itself; `global: true` on
822
827
  an op escapes to the whole document. An op name the client doesn't recognize
823
828
  logs a warning and is skipped — the rest of the chain still applies.
824
829
 
830
+ In development and test (`verbose_errors`), an op whose selector resolves to
831
+ **zero elements** also warns once per unique (op, selector, scope) — with a
832
+ targeted hint when the element exists but sits outside the op's scope: "use
833
+ `to: :root`" when the selector matches the component's own root (root-scoped
834
+ resolution never includes the root itself), or "use `global: true`" when it
835
+ matches only inside a nested reactive root or elsewhere in the document.
836
+ Production stays silent — a zero-match no-op is legitimate there.
837
+
825
838
  **Attributes, focus, dispatch, and transitions.** Beyond visibility and classes,
826
839
  the same chain covers the rest of the client-only vocabulary:
827
840
 
@@ -1092,6 +1105,82 @@ field; a missing owned field reads as blank — fail-closed). Every referenced
1092
1105
  field must be owned by the declaring root; a target whose fields are all
1093
1106
  unowned is left alone, like the single-field skip.
1094
1107
 
1108
+ ### Client-only drafts (`reactive_persist`)
1109
+
1110
+ "Don't make me start over": a public application form, a multi-step wizard,
1111
+ a long comment box — the user types, navigates away, comes back, and expects
1112
+ their draft. Nothing the server needs until submit, no signed-in user to
1113
+ autosave for. `reactive_persist` (#239) is the `reactive_show`-shaped answer:
1114
+ a **declared, client-only** binding over the fields the root **owns**, no
1115
+ token, no POST, no expression surface — the generic controller keeps a
1116
+ `localStorage` draft and every hand-rolled "local save" Stimulus controller
1117
+ goes away.
1118
+
1119
+ ```ruby
1120
+ class ApplicationForm < ApplicationComponent
1121
+ include Phlex::Reactive::ClientBindings # or the full Component
1122
+ reactive_scope :apply
1123
+
1124
+ def view_template
1125
+ form(action: "/applications", method: "post") do
1126
+ div(**mix(reactive_root(id: "apply"), reactive_persist(key: "village-apply", ttl: 7.days))) do
1127
+ input(**reactive_field(:name)) # persisted
1128
+ textarea(**reactive_field(:bio)) # persisted
1129
+ input(name: "fuckery", **reactive_persist_skip) # honeypot — never
1130
+ input(type: "hidden", name: "apply[tz]") # hidden — never (default)
1131
+ button(**on_client(:click, js.persist_state(step: 2))) { "Next" }
1132
+ button(**on_client(:click, js.persist_clear)) { "Discard draft" }
1133
+ button(type: "submit") { "Apply" }
1134
+ end
1135
+ end
1136
+ end
1137
+ end
1138
+ ```
1139
+
1140
+ Spread it on the **root** (`mix` with `reactive_root`), **once per root**. One
1141
+ wire attr: `data-reactive-persist='{"key":"village-apply","ttl":604800,"debounce":300}'`.
1142
+
1143
+ - **Write** — on `input` (trailing-edge debounce, `debounce:` ms, default 300)
1144
+ and immediately on `change`; a pending write is **flushed on disconnect**, so
1145
+ a fast Turbo navigation never loses the last keystrokes. The snapshot is a
1146
+ full pass over the owned controls: radios store the checked value, checkboxes
1147
+ the checked state, `<select multiple>` an array, everything else `.value`.
1148
+ - **Restore** — on connect, **first** among the client bindings, so a
1149
+ `reactive_show` section, `reactive_on_complete` (armed, never fired),
1150
+ `reactive_filter` and a `reactive_compute` root all read the restored values
1151
+ on first paint — no synthetic events. A morph or broadcast re-render is
1152
+ server truth and is **never** re-restored.
1153
+ - **`restore: :blank`** (default) — a draft value lands only in a control the
1154
+ server rendered **blank**, so a 422 re-render's submitted values beat an
1155
+ older draft. `restore: :always` lets the draft win.
1156
+ - **Clear** — a successful `turbo:submit-end` of the form that contains the
1157
+ root, `ttl` expiry (checked on read; default `7.days`), or `js.persist_clear`.
1158
+ A successful *reactive* action does **not** clear on its own — chain
1159
+ `reply.js(js.persist_clear)` from the action when it should.
1160
+ - **`fields:`** narrows the set to declared names (scope-aware symbols):
1161
+ `reactive_persist(key: "k", fields: %i[name bio])`.
1162
+ - **Never persisted**: `type=hidden/file/password/submit/button/reset/image`,
1163
+ anything carrying `reactive_persist_skip`, a nested reactive root's controls,
1164
+ and rich-text/`contenteditable` editors (they aren't `input/select/textarea`).
1165
+ `autocomplete="off"` is **not** an implicit skip — a wizard often sets it
1166
+ form-wide. **Honeypots must opt out** (`reactive_persist_skip`) or sit outside
1167
+ the root: an invisible-captcha text input looks like any other field.
1168
+ - **State bag** — `js.persist_state(step: 2)` merges a flat hash of scalars
1169
+ into the same draft (a wizard's current step). On restore the root carries
1170
+ `data-reactive-persist-state='{"step":2}'` and dispatches a bubbling
1171
+ `reactive:persist-restored` event (`detail: { key, fields, state }`) — the
1172
+ hook for your own wizard controller to jump to the saved step.
1173
+ - **Storage failures are silent** — a private window, a quota error or blocked
1174
+ storage degrades to "no draft". Under `Phlex::Reactive.debug` the controller
1175
+ prints one `console.info` naming the failure so a dev sees why nothing came
1176
+ back.
1177
+
1178
+ Threat model: values are replayed via `.value`/`.checked` only (never HTML) — a
1179
+ tampered draft can only fill what the user could type. PII sits in this
1180
+ browser's `localStorage` for `ttl`; the submit-clear and `ttl` are the
1181
+ shared-computer mitigation. `persist_state`/`persist_clear` are **actor-only**
1182
+ ops (refused by `broadcast_to(js:)`). See the [security page](docs/security.md).
1183
+
1095
1184
  ### Client-side computes (`reactive_compute` + `reactive_text`)
1096
1185
 
1097
1186
  Some math should feel instant with **no round trip** — a NEW, unsaved record's
@@ -2600,6 +2689,19 @@ helper — a child row rendering a trigger for its container's action) is skippe
2600
2689
  `on_client` triggers are never checked (they aren't declared actions). The
2601
2690
  server's default-deny stays the security boundary — this is a dev-time courtesy.
2602
2691
 
2692
+ The flag reaches the **client** too: reactive roots (and server-emitted
2693
+ `reactive:js` streams) carry `data-reactive-verbose="true"` when it is on, and
2694
+ the client op interpreter uses that gate to `console.warn` when a client op —
2695
+ `on_client`, a reducer's `$ops`, `reply…js(...)`, `broadcast_js_to`, or a
2696
+ busy/optimistic hint — resolves **zero targets**. The warning names the op, the
2697
+ selector, and the scoping root, dedupes per unique case per page, and hints the
2698
+ documented escape (`to: :root` / `global: true`) when the element exists but
2699
+ was filtered out by root scoping or the nested-root ownership rule — the trap
2700
+ where the element is right there in the DOM, just outside the op's scope. A
2701
+ `reactive:js` stream whose `target` root id has left the DOM warns the same
2702
+ way instead of silently dropping its ops. Production (flag off) renders no
2703
+ attribute and stays byte-identical and silent.
2704
+
2603
2705
  See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.
2604
2706
 
2605
2707
  ---
@@ -39,9 +39,17 @@ module Phlex
39
39
  # #165 security), so the defer endpoint accepts them ONLY back from this
40
40
  # same actor. The defer token is built in response_streams (inside this
41
41
  # block via the Defer builder), so the binding must be established here.
42
- Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
43
- Phlex::Reactive.instrument("action", event) do
44
- create_action(event)
42
+ # Thread the ACTOR's url_options (protocol/host/port) into the reply
43
+ # render (issue #232): the reply renders through the memoized
44
+ # off-request view context, whose process-default url_options emit the
45
+ # wrong host for absolute URL helpers on a multi-host app. Broadcasts
46
+ # fired inside the action are exempted at their render (see
47
+ # Streamable.broadcast_component) — subscribers can be on other hosts.
48
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
49
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
50
+ Phlex::Reactive.instrument("action", event) do
51
+ create_action(event)
52
+ end
45
53
  end
46
54
  end
47
55
  end
@@ -62,9 +70,14 @@ module Phlex
62
70
  # so a leaked token can't be exchanged here for this actor's render (and
63
71
  # its embedded fresh identity token). Unbound requests (no session) are
64
72
  # unchanged.
65
- Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
66
- Phlex::Reactive.instrument("defer", event) do
67
- deferred_action(event)
73
+ # The defer PULL is an actor request too (issue #232) — its render gets
74
+ # the same request-derived url_options as the action reply. The PUSH
75
+ # lane (job → SSE) has no request and stays on process defaults.
76
+ Phlex::Reactive.with_url_options(Phlex::Reactive.url_options_for(request)) do
77
+ Phlex::Reactive.with_defer_binding(Phlex::Reactive.defer_binding_for(request)) do
78
+ Phlex::Reactive.instrument("defer", event) do
79
+ deferred_action(event)
80
+ end
68
81
  end
69
82
  end
70
83
  end