phlex-reactive 0.4.1 → 0.4.3

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: 786d397de0be7e32424c73079906cdf8d32d0325727d06027025d888693754e6
4
- data.tar.gz: b080f12ff8c8f4271066993548f9c951278fe2dacabf9b7d21c2113704f58da4
3
+ metadata.gz: 63629dadef998637b9b65f885a00d54297821f405599a64c103e3b482e991fdc
4
+ data.tar.gz: 4826a5cf276f46cdf99575cb624169bacbbc3bf85115ca1256542e4c7aeb6f6b
5
5
  SHA512:
6
- metadata.gz: 316b9b2094490dd5045d1362276a8f31ba4deee96df08e6614dc9db8f62a2ceb822f5a474d2204dcb65f7ed1bb27b3c99303f14a180a336c2d5bc3d21c3bf31a
7
- data.tar.gz: f54ecfe74e977b3d4a399cd004071792a3a7245636d1c82f19441b0240fb2d24d32bb072a3ae863a9e04efcab5a31182560628237f72bf77ff73ac387592e4c4
6
+ metadata.gz: 7561f6119d33b19334078c64570bb2fc7e6e9064b0ee90c4bcbac21dbbe2e07d5ccdb0854d146848efee66bb084c895eb89a853c3931de4c7fd115a0594163d9
7
+ data.tar.gz: 5220bfda32b47381507fed0d3a9a9d6620f06e9a10dbd8eb877fc07a0f420a8a1e3ff2dd06805059b0b0b7f550bb76cac81a7899f258bb85508643ef61dc8e35
data/CHANGELOG.md CHANGED
@@ -72,6 +72,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
72
72
 
73
73
  ### Fixed
74
74
 
75
+ - **Client mirror of #44: collections of *reactive* rows were STILL add-once-only in
76
+ the browser — `#extractToken` read the FIRST token in the response, not this
77
+ controller's own (#46).** The server fix in 0.4.2 (#44) made the `add` response
78
+ correct — the container's fresh `reactive:token` stream is present — but the
79
+ client still broke. After each action the reactive controller stores the
80
+ response's fresh token for its NEXT dispatch; `#extractToken` did
81
+ `html.match(/data-reactive-token-value="([^"]+)"/)` — the FIRST match in the whole
82
+ body. On a collection of reactive rows the response is, in body order: the
83
+ appended/prepended ROW (carrying its OWN token, since a reactive row is itself a
84
+ `data-controller="reactive"` root) FIRST, then the container's `reactive:token`
85
+ refresh LAST. So the list controller stored the ROW's token; its second dispatch
86
+ sent a row token → failed verification → the second add silently did nothing. The
87
+ fix reads the token that RE-RENDERS this controller's own element id — preferring
88
+ the dedicated `reactive:token` stream targeting `this.element.id`, then a self
89
+ `replace`/`update` of it — and otherwise keeps the existing token (never adopting
90
+ a child row's or a sibling component's token). This is the exact client analogue
91
+ of the #44 server rule: a stream "carries the token for C" only when it re-renders
92
+ C itself, never when it inserts children into C. A request-level test can't catch
93
+ this (the server response is already correct); covered by a bun unit test on the
94
+ token selection AND a real two-click browser test (a collection of reactive rows →
95
+ three rows after three adds) under Puma + Falcon. Refs cosmos#1939, #44, #30.
96
+
97
+ - **Collections of *reactive* rows were still add-once-only — a prepended/appended
98
+ row's OWN token suppressed the container's token refresh (#44).** When an action
99
+ appended/prepended a *reactive* child row into a container whose `#id` equals the
100
+ append target (the most common collection shape — rows go directly into the
101
+ container element), that single `append`/`prepend` stream carried BOTH
102
+ `target="<container>"` and the row's own `data-reactive-token-value` (embedded in
103
+ the `<template>`). `carries_token_for?` matched
104
+ `include?("data-reactive-token-value") && include?(target)` in that one string →
105
+ both true → it concluded the container's token was already fresh and **skipped**
106
+ the container's `reactive:token` refresh. So the container (which owns the
107
+ add/remove trigger) never rolled its token forward, and the second dispatch was
108
+ rejected with the stale token — the list silently stopped after the first add.
109
+ This hit both the hand-rolled `reply.streams(Row.prepend(...), …)` form **and**
110
+ the `reactive_collection` / `reply.append`/`reply.prepend` helper, since they
111
+ share the gate. Fixed by tightening `carries_token_for?`: a stream "carries the
112
+ token for C" only when its action RE-RENDERS C itself (`replace`/`update`/
113
+ `reactive:token` at C's target) — `append`/`prepend` (which insert *children*
114
+ into C) never count, because a reactive child's token is not the container's. The
115
+ idempotency the gate provided is preserved (a caller's own self-replace still
116
+ suppresses the duplicate token) and the #30 sibling case still refreshes
117
+ correctly. Covered at the request level: a reactive row appended twice in a row
118
+ (the second using the token the first rolled forward) now succeeds, and each
119
+ response carries a `reactive:token` stream targeting the container with a
120
+ non-empty token. Refs cosmos#1939, #35, #30.
121
+
75
122
  - **0.4.0 regression: re-renders lost the `request`, crashing `form_authenticity_token`
76
123
  and other request-dependent helpers (#42).** The 0.4.0 render-path perf rework
77
124
  built the cached off-request view context from a bare
@@ -107,15 +107,49 @@ module Phlex
107
107
  streams
108
108
  end
109
109
 
110
- # True when one of `streams` already carries a fresh token TARGETING this
111
- # component i.e. the caller hand-built the actor's own token-bearing
112
- # stream, so appending to_stream_token would double it. Scoped to the
113
- # component's target id (not a global substring) so a sibling component's
114
- # stream, which carries its OWN token for a DIFFERENT target, doesn't fool
115
- # us into skipping this component's refresh.
110
+ # Actions that RE-RENDER the component's own root (so the root's fresh
111
+ # data-reactive-token-value rolls the signed token forward). `append`/
112
+ # `prepend` are deliberately excluded: they insert CHILDREN into the
113
+ # component, and a reactive child carries its OWN token that child token is
114
+ # not the component's (issue #44). reactive:token is our inert token-only
115
+ # refresh; replace/update re-render the root.
116
+ SELF_RENDER_ACTIONS = %w[replace update reactive:token].freeze
117
+ private_constant :SELF_RENDER_ACTIONS
118
+
119
+ # True when one of `streams` already carries a fresh token by RE-RENDERING
120
+ # this component itself — i.e. the caller hand-built the actor's own
121
+ # token-bearing stream, so appending to_stream_token would double it.
122
+ #
123
+ # The match requires BOTH (a) the stream's action re-renders the component's
124
+ # ROOT (replace/update/reactive:token — never append/prepend, which insert
125
+ # children) AND (b) it targets the component's id AND (c) it actually carries
126
+ # a token. This is stricter than a same-string substring check on purpose:
127
+ # * A sibling component's replace targets a DIFFERENT id → (b) fails, so we
128
+ # still refresh ours (issue #30).
129
+ # * A reactive child row appended/prepended INTO the component carries its
130
+ # own token at the component's target, but the action is append/prepend →
131
+ # (a) fails, so we still refresh the CONTAINER's token (issue #44). Before
132
+ # this, the child's token at the container target suppressed the
133
+ # container's refresh and the list was add-once-only.
116
134
  def carries_token_for?(streams, component)
117
135
  target = %(target="#{ERB::Util.html_escape(component.id)}")
118
- streams.any? { it.include?("data-reactive-token-value") && it.include?(target) }
136
+ streams.any? do
137
+ it.include?("data-reactive-token-value") &&
138
+ it.include?(target) &&
139
+ self_render_stream_for?(it, target)
140
+ end
141
+ end
142
+
143
+ # Does this turbo-stream's OPENING tag re-render `target` itself? Matches a
144
+ # `<turbo-stream action="<self-render>" ... target="<id>">` opening tag — the
145
+ # action and target on the SAME tag — so a child row's token embedded in an
146
+ # append/prepend `<template>` can never count as the container's own refresh.
147
+ def self_render_stream_for?(stream, target)
148
+ open_tag = stream[/<turbo-stream\b[^>]*>/]
149
+ return false unless open_tag&.include?(target)
150
+
151
+ action = open_tag[/\baction="([^"]+)"/, 1]
152
+ SELF_RENDER_ACTIONS.include?(action)
119
153
  end
120
154
 
121
155
  # A 200 turbo-stream carrying a namespaced custom action the client turns
@@ -64,6 +64,13 @@ export function registerReactiveActions() {
64
64
  registerReactiveToken()
65
65
  }
66
66
 
67
+ // Escape a DOM id for safe interpolation into a RegExp (an id can legally contain
68
+ // regex metacharacters like `.`/`:` — e.g. an `escape:`-namespaced or dotted id).
69
+ // Used by #extractToken to match the stream that re-renders THIS element by id.
70
+ export function escapeRegExp(string) {
71
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
72
+ }
73
+
67
74
  if (typeof window !== "undefined") {
68
75
  if (window.Turbo) registerReactiveActions()
69
76
  else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
@@ -286,9 +293,46 @@ export default class extends Controller {
286
293
  this.#tokenCache = value
287
294
  }
288
295
 
296
+ // Read the next token for THIS controller — the one that re-renders THIS
297
+ // element's id, never just the first token in the body (issue #46). On a
298
+ // collection of REACTIVE rows the prepended/appended ROW carries its OWN
299
+ // data-reactive-token-value and it sorts FIRST in the response; the list's own
300
+ // fresh token rides a trailing `reactive:token` stream targeting the container.
301
+ // Grabbing the first match stored the ROW's token, so the list's SECOND
302
+ // dispatch sent a row token → failed verification → add-once-only. This mirrors
303
+ // the server's carries_token_for? (#44): a stream carries OUR token only when it
304
+ // RE-RENDERS our id (reactive:token / replace / update of `this.element.id`) —
305
+ // append/prepend insert children and never count. Returns undefined when no
306
+ // stream re-renders our id, so #currentToken keeps its existing value.
289
307
  #extractToken(html) {
290
- const match = html.match(/data-reactive-token-value="([^"]+)"/)
291
- return match?.[1]
308
+ const id = this.element.id
309
+ if (!id) {
310
+ // No id to self-match (shouldn't happen for a reactive root). Fall back to
311
+ // the legacy first-token behavior so a single-component response still works.
312
+ return html.match(/data-reactive-token-value="([^"]+)"/)?.[1]
313
+ }
314
+
315
+ // The dedicated token-only refresh for THIS element (partial updates / the
316
+ // collection container) — an attribute on the <turbo-stream> itself.
317
+ const tokenStream = html.match(
318
+ new RegExp(
319
+ `<turbo-stream\\b[^>]*\\baction="reactive:token"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*\\bdata-reactive-token-value="([^"]+)"`,
320
+ ),
321
+ )
322
+ if (tokenStream) return tokenStream[1]
323
+
324
+ // A full self re-render: a replace/update of THIS element whose template root
325
+ // carries the fresh token. Scope the token search to that one stream so a
326
+ // sibling/child token elsewhere in the body can't leak in.
327
+ const selfStream = html.match(
328
+ new RegExp(
329
+ `<turbo-stream\\b[^>]*\\baction="(?:replace|update)"[^>]*\\btarget="${escapeRegExp(id)}"[^>]*>([\\s\\S]*?)</turbo-stream>`,
330
+ ),
331
+ )
332
+ if (selfStream) return selfStream[1].match(/data-reactive-token-value="([^"]+)"/)?.[1]
333
+
334
+ // Nothing re-rendered our id — keep the current token.
335
+ return undefined
292
336
  }
293
337
 
294
338
  // True when `el` is collected by THIS reactive root and not by a nested one.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.1"
5
+ VERSION = "0.4.3"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson