phlex-reactive 0.16.12 → 0.16.13

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.
@@ -854,8 +854,18 @@ function refusedHeader(response) {
854
854
  // (streamOwnReply wraps its render, innermost); a reply none of whose streams
855
855
  // rendered releases now. An error reply (keep true) never marks either, but
856
856
  // releases nothing: the server refused those values rather than answering them.
857
- function renderOwnReply(root, html, keep = null) {
858
- const own = { root, streams: 0, keep }
857
+ //
858
+ // Returns a promise that settles once the reply is drawn (issue #464), so the
859
+ // dispatch lane leaves the activity counter after the morph, not a repaint
860
+ // before it. Every listener has had its say by the time renderStreamMessage
861
+ // returns (dispatch is synchronous), so a stream whose event was not cancelled
862
+ // is one Turbo will render: the promise waits for each such render to finish
863
+ // (the innermost wrapper below — after an exit effect, after the opt-in
864
+ // client's hold). With none left to wait for — no stream, or every render
865
+ // prevented — it settles after one repaint. Never later than `cap` ms: a render
866
+ // chain that never finishes must not wedge the request queue.
867
+ function renderOwnReply(root, html, keep = null, cap = 30000) {
868
+ const own = { root, streams: 0, keep, events: [] }
859
869
  const previous = replyRoot
860
870
  replyRoot = own
861
871
  try {
@@ -864,6 +874,21 @@ function renderOwnReply(root, html, keep = null) {
864
874
  replyRoot = previous
865
875
  }
866
876
  if (own.streams === 0) releaseHeld(root, keep)
877
+ const renders = own.events.filter((entry) => !entry.event.defaultPrevented).map((entry) => entry.done)
878
+ // Resolves with the first render's error, if one threw (Turbo logs it).
879
+ const drawn = renders.length > 0 ? Promise.all(renders).then((errors) => errors.find(Boolean)) : nextRepaint()
880
+ let timer
881
+ const bound = new Promise((resolve) => (timer = setTimeout(resolve, cap)))
882
+ return Promise.race([drawn, bound]).finally(() => clearTimeout(timer))
883
+ }
884
+
885
+ // Turbo's own nextRepaint: a frame, or a task in a hidden tab (where frames
886
+ // stop) and wherever there is no requestAnimationFrame.
887
+ function nextRepaint() {
888
+ return new Promise((resolve) => {
889
+ if (document.visibilityState === "hidden" || typeof requestAnimationFrame !== "function") setTimeout(resolve, 0)
890
+ else requestAnimationFrame(() => resolve())
891
+ })
867
892
  }
868
893
 
869
894
  function streamOwnReply(event) {
@@ -872,15 +897,22 @@ function streamOwnReply(event) {
872
897
  const render = detail?.render
873
898
  if (!own || typeof render !== "function") return
874
899
  own.streams++
900
+ let finished
901
+ own.events.push({ event, done: new Promise((resolve) => (finished = resolve)) })
875
902
  detail.render = async (el) => {
876
903
  replying.set(own.root, (replying.get(own.root) || 0) + 1)
904
+ let failure
877
905
  try {
878
906
  return await render(el)
907
+ } catch (error) {
908
+ failure = error
909
+ throw error
879
910
  } finally {
880
911
  const left = replying.get(own.root) - 1
881
912
  if (left) replying.set(own.root, left)
882
913
  else replying.delete(own.root)
883
914
  releaseHeld(own.root, own.keep)
915
+ finished(failure)
884
916
  }
885
917
  }
886
918
  }
@@ -988,9 +1020,20 @@ export function registerReactiveDev() {
988
1020
  // is fully applied by the time the call returns — there is no async window to await
989
1021
  // (the "value settles a beat after a morph/seed" case the issue describes is
990
1022
  // covered by the System test helpers' re-resolve-by-id polling, not this counter).
1023
+ //
1024
+ // Each busy window (busy edge to idle edge) also collects the failures raised
1025
+ // inside it (issue #464): a dispatch that failed (every reactive:error kind,
1026
+ // apply included), a feature that could not load, a deferred render that
1027
+ // failed. reactive:idle's detail carries them as `errors`, and <html> carries
1028
+ // data-reactive-errors (the same list, as JSON) from that idle edge until the
1029
+ // next busy edge — so a system test can tell a failed round trip from one that
1030
+ // rendered. A failure raised while nothing is in flight belongs to no window
1031
+ // and is not kept.
991
1032
  export const ACTIVE_ATTR = "data-reactive-active"
1033
+ export const ERRORS_ATTR = "data-reactive-errors"
992
1034
 
993
1035
  let activityCount = 0
1036
+ let activityErrors = []
994
1037
 
995
1038
  // Increment the global in-flight count; on the 0 -> 1 edge, mark <html> and fire
996
1039
  // reactive:busy. Fully defensive — a non-browser/test document with no
@@ -998,7 +1041,17 @@ let activityCount = 0
998
1041
  // side effects (a global signal must never throw during bootstrap or a round trip).
999
1042
  export function enterReactiveActivity() {
1000
1043
  activityCount++
1001
- if (activityCount === 1) syncReactiveActivity("reactive:busy")
1044
+ if (activityCount === 1) {
1045
+ activityErrors = []
1046
+ syncReactiveActivity("reactive:busy")
1047
+ }
1048
+ }
1049
+
1050
+ // Note a failure in the open busy window: { action, kind, status, root }, each
1051
+ // null when it does not apply. Called BEFORE the failing operation exits.
1052
+ export function recordReactiveError({ action = null, kind, status = null, root = null }) {
1053
+ if (activityCount === 0) return
1054
+ activityErrors.push({ action: action ?? null, kind, status: status ?? null, root: root || null })
1002
1055
  }
1003
1056
 
1004
1057
  // Decrement the global in-flight count, clamped at 0 so an unbalanced exit can
@@ -1020,8 +1073,10 @@ export function reactiveActivityCount() {
1020
1073
  // since the module is imported once per bun run.
1021
1074
  export function resetReactiveActivity() {
1022
1075
  activityCount = 0
1076
+ activityErrors = []
1023
1077
  const root = typeof document !== "undefined" ? document.documentElement : null
1024
1078
  root?.removeAttribute?.(ACTIVE_ATTR)
1079
+ root?.removeAttribute?.(ERRORS_ATTR)
1025
1080
  }
1026
1081
 
1027
1082
  // Write the <html> marker from the current count and fire the edge event on
@@ -1031,11 +1086,15 @@ export function resetReactiveActivity() {
1031
1086
  function syncReactiveActivity(eventName) {
1032
1087
  if (typeof document === "undefined") return
1033
1088
  const root = document.documentElement
1089
+ const idle = eventName === "reactive:idle"
1034
1090
  if (typeof root?.toggleAttribute === "function") {
1035
1091
  root.toggleAttribute(ACTIVE_ATTR, activityCount > 0)
1092
+ if (idle && activityErrors.length > 0) root.setAttribute(ERRORS_ATTR, JSON.stringify(activityErrors))
1093
+ else root.removeAttribute(ERRORS_ATTR)
1036
1094
  }
1037
1095
  if (typeof document.dispatchEvent === "function" && typeof CustomEvent === "function") {
1038
- document.dispatchEvent(new CustomEvent(eventName, { detail: { count: activityCount } }))
1096
+ const detail = idle ? { count: activityCount, errors: activityErrors.slice() } : { count: activityCount }
1097
+ document.dispatchEvent(new CustomEvent(eventName, { detail }))
1039
1098
  }
1040
1099
  }
1041
1100
 
@@ -2568,8 +2627,14 @@ export function __loadReactiveFeatureForTest(name) {
2568
2627
  // modules the default bundle handed over still loaded — or, `cold`, with none
2569
2628
  // loaded at all: the state of a page that imported phlex/reactive/core.
2570
2629
  export function __resetReactiveFeaturesForTest(cold) {
2571
- // (The default entry has no table to reset: its features are static.)
2572
- if (!__SPLIT__) return
2630
+ // The default entry has no table to reset: its features are static. A second
2631
+ // copy of the runtime installs its own counters into them, though, so put
2632
+ // this instance's back.
2633
+ if (!__SPLIT__) {
2634
+ persistFeature.install(FEATURE_SHARED)
2635
+ deferFeature.install(FEATURE_SHARED)
2636
+ return
2637
+ }
2573
2638
  FEATURES.clear()
2574
2639
  for (const [name, entry] of PRODUCTION_FEATURES) FEATURES.set(name, entry)
2575
2640
  // (Only the opt-in entry registers any; with the default entry alone the
@@ -2596,6 +2661,7 @@ function forgetWaiting(root, name, pending) {
2596
2661
  const FEATURE_SHARED = {
2597
2662
  enter: enterReactiveActivity,
2598
2663
  exit: exitReactiveActivity,
2664
+ fail: recordReactiveError,
2599
2665
  count: countReactiveRequest,
2600
2666
  // The editable collector and server text (issue #351), for the draft.
2601
2667
  text: editableValue,
@@ -3219,6 +3285,7 @@ export default class extends Controller {
3219
3285
  #featureFailed(name, phase, error) {
3220
3286
  logFeatureFailure(name, phase, error)
3221
3287
  this.#markError("feature")
3288
+ recordReactiveError({ kind: "feature", root: this.element.id })
3222
3289
  // A lazy shell whose module will never come (a failed import stays failed
3223
3290
  // until the page is reloaded) must not go on shimmering as if it loaded.
3224
3291
  if (phase === "load" && this.element.getAttribute?.("data-reactive-defer-pending")) {
@@ -4145,6 +4212,7 @@ export default class extends Controller {
4145
4212
  }
4146
4213
  return this.#enqueue(action, rawParams)
4147
4214
  }
4215
+ recordReactiveError({ action, kind: extra.kind, status: extra.status, root: this.element.id })
4148
4216
  this.#emit("reactive:error", { action, params: sentParams, ...extra, retry })
4149
4217
  }
4150
4218
 
@@ -4390,7 +4458,9 @@ export default class extends Controller {
4390
4458
  if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
4391
4459
  else devtoolsFeature.recordBody(debug, errorBody, fresh)
4392
4460
  }
4393
- renderOwnReply(this.element, errorBody, true)
4461
+ const drawn = renderOwnReply(this.element, errorBody, true, this.#timeoutMs())
4462
+ settle?.handoff?.()
4463
+ await drawn
4394
4464
  }
4395
4465
  this.#markError("http")
4396
4466
  this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
@@ -4424,7 +4494,7 @@ export default class extends Controller {
4424
4494
  // focused input + caret on unchanged nodes — see issue #28. As the root's
4425
4495
  // own reply, it clears what the root held (issue #370), but what it
4426
4496
  // refused (issue #413).
4427
- renderOwnReply(this.element, html, refusedHeader(response))
4497
+ const drawn = renderOwnReply(this.element, html, refusedHeader(response), this.#timeoutMs())
4428
4498
  // Debug-only (issue #181): the morph may apply a microtask later, so check
4429
4499
  // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
4430
4500
  // null — zero cost.
@@ -4437,8 +4507,24 @@ export default class extends Controller {
4437
4507
  // complete a tick later. Apps needing post-morph timing listen to Turbo's
4438
4508
  // own events; this one is for "the action round trip succeeded".
4439
4509
  this.#emit("reactive:applied", { action, params: allParams, html })
4510
+ // The round trip ends once the reply is drawn (issue #464): the finally's
4511
+ // settle (and with it the activity counter's idle edge) runs after the
4512
+ // morph, and the next queued action starts after it too. The busy state
4513
+ // reverts first, still a repaint before the streams render.
4514
+ settle?.handoff?.()
4515
+ // A stream that threw while Turbo rendered it is a client-side apply
4516
+ // failure. Recorded here, not in the catch below: the rest of the reply
4517
+ // is already drawn, so nothing reverts (the optimistic inverse would undo
4518
+ // over the server's state) and the reply still counts as applied.
4519
+ const failure = await drawn
4520
+ if (failure) {
4521
+ this.#markError("apply")
4522
+ recordReactiveError({ action, kind: "apply", root: this.element.id })
4523
+ this.#emit("reactive:error", { action, params: allParams, kind: "apply" })
4524
+ }
4440
4525
  // What core.proceed resolves with: a feature can tell a reply that
4441
- // applied from a failure (every other exit returns nothing).
4526
+ // applied from a failure (every other exit returns nothing). A reply
4527
+ // with a stream that threw still applied the rest.
4442
4528
  return html
4443
4529
  } catch (error) {
4444
4530
  // The server already processed this action successfully (we're past the
@@ -4449,6 +4535,8 @@ export default class extends Controller {
4449
4535
  // action the server already completed.
4450
4536
  console.error("[phlex-reactive] action error", error)
4451
4537
  this.#revertOptimistic(pending.inverse)
4538
+ this.#markError("apply")
4539
+ recordReactiveError({ action, kind: "apply", root: this.element.id })
4452
4540
  this.#emit("reactive:error", { action, params: allParams, kind: "apply" })
4453
4541
  } finally {
4454
4542
  // Settle the loading state (issue #99): decrement the pending counter,
@@ -5019,15 +5107,28 @@ export default class extends Controller {
5019
5107
  // #perform runs in its finally on EVERY exit path (success or any failure).
5020
5108
  enterReactiveActivity()
5021
5109
 
5110
+ // Two steps (issue #464). `handoff` reverts this enqueue's busy state when
5111
+ // the reply is handed to Turbo, a repaint before it renders, so an in-place
5112
+ // morph's server state (a trigger it renders disabled) wins over the undo.
5113
+ // The settle itself leaves the activity count only once the reply is drawn,
5114
+ // running the hand-off first if no reply got that far.
5115
+ let unbusied = false
5022
5116
  let settled = false
5023
- return () => {
5117
+ const handoff = () => {
5118
+ if (unbusied) return
5119
+ unbusied = true
5120
+ this.#unmarkBusy(action, trigger)
5121
+ // Busy reverts regardless of outcome, guarded per element.
5122
+ for (const op of pending.undo) op()
5123
+ }
5124
+ const settle = () => {
5024
5125
  if (settled) return // one settle per enqueue, even if called twice
5025
5126
  settled = true
5026
- this.#unmarkBusy(action, trigger)
5127
+ handoff()
5027
5128
  exitReactiveActivity()
5028
- // Busy reverts on SETTLE regardless of outcome, guarded per element.
5029
- for (const op of pending.undo) op()
5030
5129
  }
5130
+ settle.handoff = handoff
5131
+ return settle
5031
5132
  }
5032
5133
 
5033
5134
  // Layer 1 — the always-on busy markers. Trigger + root carry the action token;
@@ -1139,20 +1139,40 @@ module Phlex
1139
1139
  # `from:` values are RAW CSS selectors (the escape-hatch posture of
1140
1140
  # reactive_filter/reactive_tags) — the sources are author-owned markup,
1141
1141
  # not reactive_field bindings — resolved root-scoped (#15 ownership).
1142
+ #
1143
+ # `clear: :reset` (issue #466) puts each source back to its RENDERED
1144
+ # default instead of emptying it, the way a form reset would: a
1145
+ # default-on checkbox stays ticked for the next entry, a text input
1146
+ # returns to its `value`, a select to its `selected` option.
1142
1147
  def reactive_nested_add(association, from: nil, clear: false)
1143
1148
  data = {
1144
1149
  action: "click->reactive#nestedAdd",
1145
1150
  reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
1146
1151
  }
1152
+ clear_param = nested_clear_param!(clear)
1147
1153
  unless from.nil?
1148
1154
  data[:reactive_nested_from_param] = nested_from_param!(from)
1149
- # STRING "true", not boolean: a valueless boolean attr reads "" (falsy)
1150
- # client-side — the on()/tags precedent.
1151
- data[:reactive_nested_clear_param] = "true" if clear
1155
+ data[:reactive_nested_clear_param] = clear_param if clear_param
1152
1156
  end
1153
1157
  { type: "button", data: }
1154
1158
  end
1155
1159
 
1160
+ # The clear: wire value: "true" (empty the sources), "reset" (back to
1161
+ # their rendered defaults, issue #466), or nil (leave them). STRINGS,
1162
+ # not booleans: a valueless boolean attr reads "" (falsy) client-side —
1163
+ # the on()/tags precedent. Anything else is a typo: fail at render.
1164
+ def nested_clear_param!(clear)
1165
+ case clear
1166
+ when false, nil then nil
1167
+ when true then "true"
1168
+ when :reset then "reset"
1169
+ else
1170
+ raise ArgumentError,
1171
+ "reactive_nested_add(clear:) takes true (empty the sources), :reset (put them back to " \
1172
+ "their rendered defaults) or false, got #{clear.inspect}"
1173
+ end
1174
+ end
1175
+
1156
1176
  # Validate + compile a fill-then-add `from:` map into its JSON wire
1157
1177
  # (issue #208). Each key is a ROW FIELD name (plain identifier, like the
1158
1178
  # association); each value is a SOURCE CONTROL selector (non-blank). An
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module TestHelpers
6
+ module System
7
+ # Raised by wait_for_reactive when the busy window it waited on ended with
8
+ # failures (issue #464): the client's <html data-reactive-errors> list.
9
+ #
10
+ # reactive action "increment" failed (http 500) on #flaky-counter
11
+ #
12
+ # #errors is that list, each entry { action:, kind:, status:, root: }.
13
+ class ReactiveReplyError < StandardError
14
+ attr_reader :errors
15
+
16
+ def initialize(errors)
17
+ @errors = errors
18
+ super(errors.map { describe(it) }.join("; "))
19
+ end
20
+
21
+ private
22
+
23
+ def describe(error)
24
+ subject = error[:action] ? %(action "#{error[:action]}") : error[:kind].to_s
25
+ reason = [error[:kind], error[:status]].compact.join(" ")
26
+ "reactive #{subject} failed (#{reason})#{" on ##{error[:root]}" if error[:root]}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -42,6 +42,22 @@ module Phlex
42
42
  # gate (dev/test by default).
43
43
  REQUESTS_ATTR = "data-reactive-requests"
44
44
 
45
+ # The <html> attribute the client writes when a busy window ends with
46
+ # failures (issue #464): a JSON list of { action, kind, status, root },
47
+ # present from that idle edge until the next busy edge. Lockstep with
48
+ # runtime.js ERRORS_ATTR.
49
+ ERRORS_ATTR = "data-reactive-errors"
50
+
51
+ # The list in the attribute, each entry a Hash with Symbol keys. Anything
52
+ # but a JSON list (absent, garbled, Playwright's {} for a JS null) reads
53
+ # as no errors, and so does an entry that is not an object.
54
+ def self.parse_reactive_errors(raw)
55
+ parsed = raw.is_a?(::String) ? ::JSON.parse(raw, symbolize_names: true) : []
56
+ parsed.is_a?(::Array) ? parsed.grep(::Hash) : []
57
+ rescue ::JSON::ParserError
58
+ []
59
+ end
60
+
45
61
  # Parse the attribute value into { action:, defer: } Integers — zeros for
46
62
  # an absent or garbled value. Shared by the helpers and
47
63
  # ReactiveRequestsMatcher.
@@ -74,7 +90,13 @@ module Phlex
74
90
  # (a deferred render behind a real job). Returns nil; call it as a barrier
75
91
  # BEFORE asserting a settled value if you are not already using one of the
76
92
  # waiting matchers below.
77
- def wait_for_reactive(timeout: nil)
93
+ #
94
+ # The idle edge comes after the replies are drawn, and it records what
95
+ # failed in the window that just ended (issue #464). If anything did, this
96
+ # raises ReactiveReplyError naming each failed action, kind and status, so
97
+ # a 500 or a timeout fails here rather than one assertion later.
98
+ # `allow_error: true` only waits, for a test that expects the failure.
99
+ def wait_for_reactive(timeout: nil, allow_error: false)
78
100
  # Scope the check to <html> via the :xpath "/html" so the marker is read
79
101
  # on the document element the client writes it to — not a descendant.
80
102
  # assert_no_selector WAITS (retries) until the marker clears or the wait
@@ -83,6 +105,13 @@ module Phlex
83
105
  # session) so it works regardless of whether the example group mixed in
84
106
  # Capybara::DSL.
85
107
  page.assert_no_selector(:xpath, "/html[@#{ACTIVE_MARKER}]", **wait_option(timeout))
108
+ return if allow_error
109
+
110
+ errors = System.parse_reactive_errors(
111
+ page.evaluate_script("document.documentElement.getAttribute(#{ERRORS_ATTR.to_json})")
112
+ )
113
+ raise ReactiveReplyError, errors if errors.any?
114
+
86
115
  nil
87
116
  end
88
117
 
@@ -252,3 +281,4 @@ module Phlex
252
281
  end
253
282
 
254
283
  require "phlex/reactive/test_helpers/system/reactive_requests_matcher"
284
+ require "phlex/reactive/test_helpers/system/reactive_reply_error"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.12"
5
+ VERSION = "0.16.13"
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.16.12
4
+ version: 0.16.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -262,6 +262,7 @@ files:
262
262
  - lib/phlex/reactive/test_helpers.rb
263
263
  - lib/phlex/reactive/test_helpers/matchers.rb
264
264
  - lib/phlex/reactive/test_helpers/system.rb
265
+ - lib/phlex/reactive/test_helpers/system/reactive_reply_error.rb
265
266
  - lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb
266
267
  - lib/phlex/reactive/version.rb
267
268
  - lib/tasks/phlex_reactive.rake