phlex-reactive 0.9.5 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e6f3431087aff47f32d503174b5242c51af5fce1bbdd7832d40a466a5c3a548
4
- data.tar.gz: 7bed02791a8b41163bd7b237aa1bb2730ff99496450783eae712bbcb1ae46052
3
+ metadata.gz: 42d923475e8b2eb3ea48a0ef076358a10dbfd7760c34709dc54af5910805ad01
4
+ data.tar.gz: a6822d8caa3e24dc863284f7bb6eafde39bc69562d4c5d14504b99e489c1e302
5
5
  SHA512:
6
- metadata.gz: f66d90ab48f9210f0bfddf08f14b2a86593118af903574432592b408920656f3d025cd7a8a067b27faf2c42b8456e8e802caf93980cfa967b3fd69eed0c80646
7
- data.tar.gz: 6e449424886a2375fc5b64788077086eb7274536eb72d843c9dc5ee0d107e688f573f1a723a283d806dac2897bf55b9e9f432f472bb157360c7d1931aff6aab3
6
+ metadata.gz: 234bcb0ca3b36e91f8f53cbbb2dd31f86f03b5a1043911e13652f3b4c2d31fe882b883035c4ab8ba2e2fc8a0f0755ac6302f2bf6d04c09d93aa2d659cd69e358
7
+ data.tar.gz: b63a02ff87733de83ad39dbea2f9754ef62fbfc79d7cf8f1d4a98e22891dae50e215bbca11b5faecefe9a454a2b0794c3fbbf870ee4da2ca8ec025aea69185b7
data/CHANGELOG.md CHANGED
@@ -6,8 +6,77 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+
11
+ - **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
12
+ `broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
13
+ arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
14
+ unknown kwargs into its render locals — so on the pgbus transport the options were
15
+ **silently dropped** and `exclude: reactive_connection_id` never suppressed the
16
+ actor's own broadcast echo. They are now threaded through the thread-locals pgbus
17
+ reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
18
+ on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
19
+ end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
20
+ option was *forwarded to the method*, never that it *reached pgbus*.
21
+
22
+ ### Added
23
+
24
+ - **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
25
+ The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
26
+ cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
27
+ migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
28
+ suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
29
+ specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
30
+ Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
31
+ a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
32
+ pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
33
+ transport, not a required one.
34
+
9
35
  ### Changed
10
36
 
37
+ - **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
38
+ The four accreted dialects (the positional-field predicate kwargs
39
+ `equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
40
+ arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
41
+ replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
42
+ membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
43
+ disjunction — the distributive-law killer), and `unless:` negates. Each removed
44
+ form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
45
+
46
+ | Before (removed) | After |
47
+ |---|---|
48
+ | `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
49
+ | `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
50
+ | `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
51
+ | `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
52
+ | `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
53
+ | `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
54
+ | `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
55
+ | `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
56
+
57
+ New alongside it: **`reactive_values`** computes each binding's first-paint
58
+ `hidden:` server-side (no per-section mirror method, no flash);
59
+ **`reactive_scope :form`** lets bindings use bare field symbols
60
+ (`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
61
+ section's own controls so a switched-away value never submits. The wire is now
62
+ ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
63
+ legacy read arms for one minor (deploy overlap), removed in 0.11.
64
+
65
+ - **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
66
+ view that only shows/hides, filters, or computes client-side (no server
67
+ actions, no token) includes this instead of the full `Component`. It does NOT
68
+ pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
69
+ concern) or the token machinery — a token-less `reactive_root` with no `#id`
70
+ requirement, invisible to `phlex_reactive:doctor`. The full `Component`
71
+ includes it (one implementation), then layers Streamable + Identity on top.
72
+
73
+ - **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
74
+ The action endpoint now appends a `reactive:token` refresh stream automatically
75
+ whenever a reply does not re-render the component's root, instead of prepending
76
+ a full self-replace that could clobber a live input. Picking `.streams` vs
77
+ `.with` to keep the signed token fresh is no longer a correctness decision — a
78
+ companion-only reply refreshes the token either way.
79
+
11
80
  - **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
12
81
  that completes **without any authorization call now raises**
13
82
  `Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
data/README.md CHANGED
@@ -387,8 +387,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
387
387
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
388
388
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
389
389
  | `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). |
390
- | `reactive_show(:field, equals:/not:/in:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the named field's **current value**, client-only, zero round trip. One literal predicate: `equals:`, `not:`, or `in: [...]`; `equals: true` reads a checkbox's checked state. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
391
- | `reactive_show_targets(:field, "#id" => { equals: … })` | **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 (`reactive_show_targets(mode: { }, kind: { })`). Id selectors only (raise at render + client warn-skip); same literal predicates; toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
390
+ | `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). |
391
+ | `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..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
392
392
  | `reactive_filter(input:, option:, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root — typing in the named input shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. Optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
393
393
  | `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. |
394
394
  | `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric. |
@@ -892,52 +892,58 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
892
892
 
893
893
  `on_client` covers the *unconditional* client-only interactions; the last gap
894
894
  was **show/hide from a form field's current value** — the Alpine `x-show` /
895
- Datastar `data-show` / Livewire `wire:show` case ("reveal the details panel
896
- *while* this select isn't `none`"). That used to force a hand-written
897
- `change`-listener Stimulus controller back into an otherwise declarative form.
898
- `reactive_show` closes it: spread it onto the element to show/hide, name the
899
- controlling field, declare **one literal predicate** the generic controller
900
- toggles the `hidden` attribute from the field's current value on every
901
- `input`/`change`. Client-only, **no token, no POST, ever**:
895
+ Datastar `data-show` / Livewire `wire:show` case. `reactive_show` closes it with
896
+ ONE Ruby-native conditions language: spread it onto the element to show/hide and
897
+ declare an `if:` / `if_any:` / `unless:` condition with `where`-style values —
898
+ the generic controller toggles the `hidden` attribute from the fields' current
899
+ values on every `input`/`change`. Client-only, **no token, no POST, ever**:
902
900
 
903
901
  ```ruby
904
902
  def view_template
905
903
  div(**reactive_root) do
906
904
  select(name: "mode") { shipping_options }
907
- div(**reactive_show(:mode, not: "off", hidden: @order.mode == "off")) { shipping_details }
905
+ div(**reactive_show(unless: { mode: "off" })) { shipping_details }
908
906
 
909
907
  input(type: "checkbox", name: "gift")
910
- div(**reactive_show(:gift, equals: true, hidden: true)) { gift_message_field }
908
+ div(**reactive_show(if: { gift: true })) { gift_message_field }
911
909
 
912
910
  select(name: "size") { size_options }
913
- div(**reactive_show(:size, in: %w[l xl], hidden: true)) { surcharge_note }
911
+ div(**reactive_show(if: { size: %w[l xl] })) { surcharge_note } # membership
912
+
913
+ input(type: "number", name: "quantity")
914
+ div(**reactive_show(if: { quantity: 10.. })) { bulk_note } # threshold
914
915
  end
915
916
  end
916
917
  ```
917
918
 
918
- - **One predicate per binding** `equals:`, `not:`, or `in:` (a list); zero or
919
- two raise at render (a dead binding must not silently no-op). Values are
920
- **stringified literals** matched against the field's value never an
921
- expression, so there is no eval surface.
922
- - **Field reads follow the collection rules**: a checkbox compares its
923
- *checked* state as `"true"`/`"false"` (so `equals: true` is the checkbox
924
- formits `.value` is the constant `"on"`, and it wins over the hidden
925
- input Rails pairs with it); a radio group reads the **checked** radio's
926
- value (`""` when none is); everything else reads `.value`. Ownership is the
927
- usual rule a nested reactive component's fields and bindings belong to the
928
- nested component.
929
- - **Initial state**: the client seeds visibility at connect and reconciles
930
- after a morph, but render the initial `hidden:` yourself (from the same
931
- server state that renders the field, as above) so the first paint doesn't
932
- flash.
933
- - **Extra attrs ride through the helper** (`reactive_show(:mode, not: "off",
934
- class: "panel", data: { testid: "details" })`) and deep-merge — a bare
935
- `data:` spread *beside* it would clobber the binding, same as `on(...)`.
919
+ - **The value language**: a **Hash is an AND** (multiple keys ANDed), an
920
+ **Array is membership**, a **Range is a threshold** (`10..` 10, `..10` 10,
921
+ `...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
922
+ state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
923
+ Never an expression every term is a declared literal, so there is no eval
924
+ surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
925
+ - **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
926
+ true }, { shareholder: true, role: "individual" }]`), so
927
+ `director || (shareholder && individual)` is **one flat binding**, no nested
928
+ wrapper divs, no hand-applied distributive law.
929
+ - **First paint is computed for you** — declare `reactive_values` once
930
+ (`def reactive_values = { mode: @order.mode, gift: @order.gift? }`) and every
931
+ binding whose fields are all provided renders the correct initial `hidden:`
932
+ server-side. No per-section mirror method, no flash. An explicit `hidden:`
933
+ always wins; a per-call `values:` override merges over `reactive_values`.
934
+ - **`reactive_scope :form`** lets bindings and `reactive_values` use bare
935
+ symbols while the client resolves `[name="form[field]"]`.
936
+ - **`disable: true`** disables a hidden section's own controls so a
937
+ switched-away value never submits (the stale-value fix).
938
+ - **Field reads follow the collection rules**: a checkbox compares its *checked*
939
+ state; a radio group reads the **checked** radio's value; everything else
940
+ reads `.value`. Ownership is the usual rule — a nested reactive component's
941
+ fields and bindings belong to the nested component.
936
942
  - **Composes with computes**: a `reactive_compute` output write dispatches a
937
943
  real `input` event, so a *derived* value can drive visibility too.
938
- - Presentational only, strictly weaker than the js ops: it reads an owned
939
- field and toggles `hidden` on an owned element — no `innerHTML`, no
940
- attribute freedom. Cross-root writes take the **declared** escape below.
944
+ - Presentational only, strictly weaker than the js ops: it reads owned fields
945
+ and toggles `hidden` (+ optionally `disabled`) on owned elements — no
946
+ `innerHTML`, no attribute freedom. Cross-root writes take the escape below.
941
947
 
942
948
  **Cross-root targets (`reactive_show_targets`).** A plain `reactive_show` is
943
949
  root-scoped by design — but "a mode selector reveals dependent sections
@@ -946,13 +952,13 @@ sidebar note) routinely puts the dependents **outside** the control's root.
946
952
  `reactive_show_targets` is the declared escape, the visibility parallel of the
947
953
  [cross-root text mirror](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root):
948
954
  the component that **owns** the field declares which outside ids it governs,
949
- spread on the **root**:
955
+ spread on the **root**, using the same `where`-style values:
950
956
 
951
957
  ```ruby
952
958
  div(**mix(reactive_root, reactive_show_targets(:mode,
953
- "#advanced-tab" => { equals: "advanced" },
954
- "#advanced-panel" => { equals: "advanced" },
955
- "#basic-note" => { not: "advanced" }))) do
959
+ "#advanced-tab" => "advanced", # equals
960
+ "#advanced-panel" => "advanced",
961
+ "#premium-note" => %w[gold platinum]))) do # membership
956
962
  select(name: "mode") { mode_options }
957
963
  # …
958
964
  end
@@ -961,11 +967,10 @@ end
961
967
  Same posture as `mirror:`: **opt-in and declared, never implicit** — a plain
962
968
  `reactive_show` stays root-isolated; targets are **single id selectors only**
963
969
  (a class/compound selector raises at render AND is warn-and-skipped by the
964
- client — two-sided default-deny); the predicate is the same literal-only
965
- vocabulary; and the toggle is `hidden` only. The field read stays **owned** —
966
- you can only drive outside visibility from a field the declaring root owns. A
967
- target id not on the page is silently skipped, so a target inside an
968
- unrendered tab pane is fine.
970
+ client — two-sided default-deny); values use the same vocabulary; and the
971
+ toggle is `hidden` only. The field read stays **owned** — you can only drive
972
+ outside visibility from a field the declaring root owns. A target id not on the
973
+ page is silently skipped, so a target inside an unrendered tab pane is fine.
969
974
 
970
975
  **One call per root.** Phlex `mix` space-joins duplicate string `data:`
971
976
  values, so a *second* `reactive_show_targets` call on the same root would
@@ -973,8 +978,8 @@ concatenate two JSON payloads into an unparseable attribute (the client warns
973
978
  and ignores it). Several fields go in **one call** via the hash form:
974
979
 
975
980
  ```ruby
976
- reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
977
- kind: { "#premium-note" => { not: "basic" } })
981
+ reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
982
+ kind: { "#premium-note" => %w[gold platinum] })
978
983
  ```
979
984
 
980
985
  ### Client-side computes (`reactive_compute` + `reactive_text`)
@@ -328,8 +328,21 @@ module Phlex
328
328
  # ground-truth flag (rx_carries_token?), a raw string from a substring
329
329
  # scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
330
330
  # this guard would regress update/morph of self on an aliased id.
331
+ #
332
+ # A self-render reply (Response.replace/update/morph — subject_component
333
+ # set) whose streams somehow carry no token gets the full self-replace
334
+ # (defensive, unchanged). A companion-only reply.with (NO subject, NO
335
+ # token_component) that doesn't re-render the root gets a token-ONLY
336
+ # refresh instead — issue #180 automatic token refresh: reply.with and
337
+ # reply.streams converge, the author never picks a verb to keep the token
338
+ # fresh, and a live input is never clobbered by a forced replace.
331
339
  elsif result.render_self? && streams.none? { stream_carries_token?(it) }
332
- streams = [component.to_stream_replace, *streams]
340
+ streams =
341
+ if result.subject_component
342
+ [component.to_stream_replace, *streams]
343
+ else
344
+ [*streams, component.to_stream_token]
345
+ end
333
346
  end
334
347
 
335
348
  append_deferred_streams(streams, result)
@@ -860,30 +860,56 @@ function parseShowCompound(raw) {
860
860
  return null
861
861
  }
862
862
 
863
- // Evaluate a COMPOUND show binding (issue #176 part A): a parsed
864
- // { all: [term, …] } or { any: [term, …] } payload, where each term is
865
- // { field, <predicate> }. `fieldValue(name)` resolves the OWNED field's current
866
- // value (memoized by the caller). all: ANDs every term, any: ORs them. A term
867
- // whose field is missing (null) or whose predicate is malformed/unknown folds
868
- // as FALSE fail-closed (the issue's default-deny lean): a broken AND term can
869
- // never pass, a broken OR term can never reveal. Returns true/false for a
870
- // decidable payload, or null for a malformed payload (no all:/any: array) so
871
- // the caller warn-skips and leaves visibility alone.
872
- function compoundShowMatches(payload, fieldValue) {
863
+ // Evaluate one DNF TERM against a resolved field value (issue #180). A missing
864
+ // field (null) or a malformed/unknown predicate folds to FALSE fail-closed
865
+ // (default-deny): a broken AND term can't pass, a broken OR term can't reveal.
866
+ function dnfTermMatches(term, fieldValue) {
867
+ if (!term || typeof term !== "object" || typeof term.field !== "string") return false
868
+ // An absent owned field reads as "" identical to the server evaluator
869
+ // (ShowConditions.match? treats a missing field as blank). This keeps the
870
+ // Ruby first-paint and the client live-toggle in exact agreement (the shared
871
+ // fixture proves it). A malformed predicate still folds to false.
872
+ const value = fieldValue(term.field) ?? ""
873
+ return showPredicateMatches(term, value) === true
874
+ }
875
+
876
+ // Evaluate a DNF show payload (issue #180): { any: [group, …] } where each
877
+ // GROUP is an array of terms (terms AND within a group, groups OR). Returns
878
+ // true/false for a decidable payload, or null for a malformed one (no groups)
879
+ // so the caller warn-skips and leaves visibility alone. This is the ONE shape
880
+ // the 0.10 wire emits; showPayloadMatches routes the legacy shapes here or to
881
+ // the compatibility arm below.
882
+ function anyOfAllsMatches(groups, fieldValue) {
883
+ if (!Array.isArray(groups) || groups.length === 0) return null
884
+ // groups OR; within a group, terms AND (an empty group can't decide → false).
885
+ return groups.some((group) => Array.isArray(group) && group.length > 0 &&
886
+ group.every((term) => dnfTermMatches(term, fieldValue)))
887
+ }
888
+
889
+ // Route a parsed data-reactive-show payload to the right evaluator. The 0.10
890
+ // wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
891
+ // still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
892
+ // shape { all: [term,…] } / { any: [term,…] } where the values are flat TERM
893
+ // OBJECTS, not arrays. The nesting distinguishes them: DNF's any[0] is an Array.
894
+ // DELETE the legacy arm in 0.11.
895
+ function showPayloadMatches(payload, fieldValue) {
873
896
  if (!payload || typeof payload !== "object") return null
897
+ const any = payload.any
898
+ if (Array.isArray(any) && (any.length === 0 || Array.isArray(any[0]))) {
899
+ return anyOfAllsMatches(any, fieldValue)
900
+ }
901
+ return legacyCompoundShowMatches(payload, fieldValue)
902
+ }
903
+
904
+ // LEGACY (0.9.5, deploy-overlap only — DELETE in 0.11): the flat all:/any:
905
+ // compound fold, where terms are objects (not groups). Preserved so a morph of
906
+ // stale pre-0.10 HTML doesn't go dead.
907
+ function legacyCompoundShowMatches(payload, fieldValue) {
874
908
  const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
875
909
  if (!connective) return null
876
910
  const terms = payload[connective]
877
- if (terms.length === 0) return null // an empty compound decides nothing — skip
878
-
879
- const results = terms.map((term) => {
880
- if (!term || typeof term !== "object" || typeof term.field !== "string") return false
881
- const value = fieldValue(term.field)
882
- if (value === null) return false // no owned field with that name → fail-closed
883
- const match = showPredicateMatches(term, value)
884
- return match === true // null (malformed term) or false both fold to false
885
- })
886
-
911
+ if (terms.length === 0) return null
912
+ const results = terms.map((term) => dnfTermMatches(term, fieldValue))
887
913
  return connective === "all" ? results.every(Boolean) : results.some(Boolean)
888
914
  }
889
915
 
@@ -2345,37 +2371,57 @@ export default class extends Controller {
2345
2371
  if (typeof this.element?.querySelectorAll !== "function") return
2346
2372
 
2347
2373
  const owns = this.#ownershipFilter()
2374
+ const scope = this.element.getAttribute?.("data-reactive-scope") || null
2348
2375
  const values = new Map()
2349
2376
  // A memoized resolver shared by every binding in this pass — a field driving
2350
- // several bindings (and several terms of a compound) reads exactly once.
2377
+ // several bindings (and several DNF terms) reads exactly once. Scope-aware:
2378
+ // a bare field `director` resolves as `[name="scope[director]"]` (issue #180).
2351
2379
  const fieldValue = (name) => {
2352
- if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2380
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
2353
2381
  return values.get(name)
2354
2382
  }
2355
2383
  for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
2356
2384
  if (!owns(el)) continue // a nested root's binding is its own controller's job
2357
2385
 
2358
- // Compound all:/any: (issue #176 part A): no single -field attr; parse the
2359
- // JSON payload and fold its per-field terms.
2360
- const compoundRaw = el.getAttribute("data-reactive-show")
2361
- if (compoundRaw !== null) {
2362
- const match = compoundShowMatches(parseShowCompound(compoundRaw), fieldValue)
2363
- if (match !== null) el.hidden = !match
2386
+ // The 0.10 DNF payload (issue #180): data-reactive-show carries
2387
+ // { any: [ [term,…], ] }. The legacy flat-attr and 0.9.5-compound read
2388
+ // arms live in showPayloadMatches/showBindingMatches for deploy overlap.
2389
+ const payloadRaw = el.getAttribute("data-reactive-show")
2390
+ if (payloadRaw !== null) {
2391
+ const match = showPayloadMatches(parseShowCompound(payloadRaw), fieldValue)
2392
+ if (match !== null) this.#applyShowVisibility(el, match, owns, scope)
2364
2393
  continue
2365
2394
  }
2366
2395
 
2396
+ // LEGACY flat-attr binding (pre-0.10, deploy overlap — removed in 0.11).
2367
2397
  const name = el.getAttribute("data-reactive-show-field")
2368
2398
  if (!name) continue
2369
2399
  const value = fieldValue(name)
2370
2400
  if (value === null) continue // no owned field with that name — leave it be
2371
2401
  const match = showBindingMatches(el, value)
2372
2402
  if (match === null) continue // malformed predicate — warned + skipped
2373
- el.hidden = !match
2403
+ this.#applyShowVisibility(el, match, owns, scope)
2374
2404
  }
2375
2405
 
2376
2406
  // The cross-root pass (issue #164) shares the same owned-field memo, so a
2377
2407
  // field driving both an owned binding and an outside target reads once.
2378
- this.#syncShowTargets(owns, values)
2408
+ this.#syncShowTargets(owns, values, scope)
2409
+ }
2410
+
2411
+ // Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
2412
+ // the `disabled` of every owned named control inside it) from a match result
2413
+ // (issue #180). Disabling a hidden section's controls stops them submitting —
2414
+ // the stale-value fix. A visible section re-enables them. Controls a nested
2415
+ // reactive root owns are left alone (#15 ownership).
2416
+ #applyShowVisibility(el, match, owns, scope) {
2417
+ el.hidden = !match
2418
+ if (el.getAttribute("data-reactive-show-disable") !== "true") return
2419
+ if (typeof el.querySelectorAll !== "function") return
2420
+ for (const control of el.querySelectorAll("input[name], select[name], textarea[name]")) {
2421
+ if (owns(control)) control.disabled = !match
2422
+ }
2423
+ // The element itself may be a named control (a bare field with a binding).
2424
+ if (el.name && owns(el)) el.disabled = !match
2379
2425
  }
2380
2426
 
2381
2427
  // Apply the declared cross-root show targets (issue #164) — the visibility
@@ -2388,19 +2434,35 @@ export default class extends Controller {
2388
2434
  // unrendered tab pane is normal); a malformed predicate warn-skips its one
2389
2435
  // target while siblings still apply. With no map declared this is one
2390
2436
  // getAttribute and out.
2391
- #syncShowTargets(owns, values) {
2437
+ #syncShowTargets(owns, values, scope) {
2392
2438
  const map = this.#parseShowTargets()
2393
2439
  for (const [name, targets] of Object.entries(map)) {
2394
2440
  if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
2395
- if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
2441
+ if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
2396
2442
  const value = values.get(name)
2397
2443
  if (value === null) continue // no owned field with that name — leave them be
2398
- for (const [selector, pred] of Object.entries(targets)) {
2444
+ // Every target's terms share this one field, so a constant resolver folds
2445
+ // the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
2446
+ const resolve = () => value
2447
+ for (const [selector, group] of Object.entries(targets)) {
2399
2448
  if (!guardShowTargetSelector(selector)) continue
2400
- const match = showPredicateMatches(pred, value)
2401
- if (match === null) {
2402
- console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} skipped`)
2403
- continue
2449
+ // 0.10 wire: the value is a DNF GROUP (an array of terms, ANDed).
2450
+ // LEGACY (0.9.5, deploy overlap — DELETE in 0.11): a flat predicate
2451
+ // OBJECT ({ equals/not/in/gte… }) routed through showPredicateMatches.
2452
+ let match
2453
+ if (Array.isArray(group)) {
2454
+ if (group.length === 0) {
2455
+ console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${selector} — skipped`)
2456
+ continue
2457
+ }
2458
+ match = group.every((term) => dnfTermMatches(term, resolve))
2459
+ } else {
2460
+ const legacy = showPredicateMatches(group, value)
2461
+ if (legacy === null) {
2462
+ console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
2463
+ continue
2464
+ }
2465
+ match = legacy
2404
2466
  }
2405
2467
  for (const node of document.querySelectorAll(selector)) node.hidden = !match
2406
2468
  }
@@ -2438,10 +2500,14 @@ export default class extends Controller {
2438
2500
  // Rails pairs with it); a radio group reports the CHECKED radio's value (""
2439
2501
  // when none is); anything else reports .value first-wins. Returns null when
2440
2502
  // no owned field carries the name — the caller then leaves visibility alone.
2441
- #showFieldValue(name, owns) {
2503
+ #showFieldValue(name, owns, scope) {
2504
+ // Scope (issue #180): a bare field `director` under `data-reactive-scope=
2505
+ // "form"` resolves as `[name="form[director]"]`. A name already carrying a
2506
+ // bracket (a raw wire name the author passed) is used verbatim.
2507
+ const domName = scope && !name.includes("[") ? `${scope}[${name}]` : name
2442
2508
  let sawRadio = false
2443
2509
  let first = null
2444
- for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
2510
+ for (const el of this.element.querySelectorAll(`[name="${domName}"]`)) {
2445
2511
  if (!owns(el)) continue
2446
2512
  if (el.type === "checkbox") return el.checked ? "true" : "false"
2447
2513
  if (el.type === "radio") {