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 +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +104 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +19 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +473 -10
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +1 -1
- data/lib/phlex/reactive/component/helpers.rb +89 -0
- data/lib/phlex/reactive/component.rb +8 -1
- data/lib/phlex/reactive/js.rb +30 -0
- data/lib/phlex/reactive/response.rb +5 -1
- data/lib/phlex/reactive/show_conditions.rb +1 -2
- data/lib/phlex/reactive/streamable.rb +14 -5
- data/lib/phlex/reactive/test_helpers/system.rb +1 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +50 -0
- metadata +5 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a9ece1128621af6ad4b9b8e249c1005bcfd063e0aa70235800292f156ac4419
|
|
4
|
+
data.tar.gz: f8613e2ec77fb55d1f9b5b0b30719382ef5e52c2a34d1ff6bcc29f9a017bafc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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),
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|