phlex-reactive 0.16.11 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +75 -11
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +27 -9
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/defer.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +2 -2
- data/app/javascript/phlex/reactive/features/defer.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +2 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -6
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +6 -6
- data/app/javascript/phlex/reactive/runtime.js +114 -13
- data/lib/phlex/reactive/component/dsl.rb +3 -1
- data/lib/phlex/reactive/component/helpers.rb +23 -3
- data/lib/phlex/reactive/param_schema.rb +34 -9
- data/lib/phlex/reactive/test_helpers/system/reactive_reply_error.rb +32 -0
- data/lib/phlex/reactive/test_helpers/system.rb +31 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
|
@@ -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
|
-
|
|
858
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
2572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -288,7 +288,9 @@ module Phlex
|
|
|
288
288
|
#
|
|
289
289
|
# Param types are coerced server-side; anything not in the schema is
|
|
290
290
|
# dropped before reaching your method (no mass assignment):
|
|
291
|
-
# * Scalars — :string (default), :integer, :float, :boolean
|
|
291
|
+
# * Scalars — :string (default), :integer, :float, :boolean,
|
|
292
|
+
# :strict_integer, :strict_float (drop a blank or unparsable value;
|
|
293
|
+
# :integer and :float read it as 0)
|
|
292
294
|
# * Array of scalar — wrap the type in an array: [:integer]
|
|
293
295
|
# * Array of hash (Rails nested attributes) — wrap a hash schema:
|
|
294
296
|
# action :save, params: {
|
|
@@ -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
|
-
|
|
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
|
|
@@ -25,7 +25,8 @@ module Phlex
|
|
|
25
25
|
#
|
|
26
26
|
# A type is one of:
|
|
27
27
|
# * a scalar symbol (:string/:integer/:float/:boolean/:file/:date/
|
|
28
|
-
# :datetime/:decimal,
|
|
28
|
+
# :datetime/:decimal/:strict_integer/:strict_float,
|
|
29
|
+
# or an app-registered type)
|
|
29
30
|
# * a Hash schema ({ id: :integer, ... }) — nested object
|
|
30
31
|
# * a one-element Array ([:integer] / [{ ... }]) — array of that
|
|
31
32
|
class ParamSchema
|
|
@@ -53,7 +54,7 @@ module Phlex
|
|
|
53
54
|
# dups it so app registration doesn't mutate this template). Each entry
|
|
54
55
|
# is a callable(value) -> coerced | DROP. The scalar casts here are the
|
|
55
56
|
# EXACT ones the controller ran, so the existing request-spec matrix is
|
|
56
|
-
# unchanged.
|
|
57
|
+
# unchanged (the strict pair came later and has no controller history).
|
|
57
58
|
# rubocop:disable Style/ItBlockParameter, Style/SymbolProc
|
|
58
59
|
# Explicit `(value)` params, NOT `it` or `&:to_s`: the date/datetime/decimal
|
|
59
60
|
# casters wrap an INNER `parse_or_drop { ... }` block, and `it` there would
|
|
@@ -73,18 +74,42 @@ module Phlex
|
|
|
73
74
|
file: nil,
|
|
74
75
|
date: ->(value) { parse_or_drop { Date.iso8601(value.to_s) } },
|
|
75
76
|
datetime: ->(value) { parse_or_drop { DateTime.iso8601(value.to_s) } },
|
|
76
|
-
decimal: ->(value) { parse_or_drop { BigDecimal(value.to_s) } }
|
|
77
|
+
decimal: ->(value) { parse_or_drop { BigDecimal(value.to_s) } },
|
|
78
|
+
# Issue #460: :integer and :float above read a blank field as 0 and
|
|
79
|
+
# keep that meaning; these two drop it, so a typed 0 stays
|
|
80
|
+
# distinguishable. to_s first: a JSON body sends 12, not "12". Base 10
|
|
81
|
+
# so "0x10" isn't another number and "010" isn't octal.
|
|
82
|
+
strict_integer: ->(value) { parse_or_drop { Integer(value.to_s, 10) } },
|
|
83
|
+
strict_float: ->(value) { parse_or_drop { strict_float(value.to_s) } }
|
|
77
84
|
}
|
|
78
85
|
end
|
|
79
86
|
# rubocop:enable Style/ItBlockParameter, Style/SymbolProc
|
|
80
87
|
|
|
81
88
|
private
|
|
82
89
|
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
90
|
+
# Float() alone is not strict: it reads "0x10" as 16.0 (:strict_integer's
|
|
91
|
+
# base 10 refuses the same string), takes "1e400" to Infinity and "1e-400"
|
|
92
|
+
# to a fabricated 0.0 without raising, and warns on stderr for both under
|
|
93
|
+
# -w, once per client value. So BigDecimal screens first: it refuses hex
|
|
94
|
+
# and never warns, and nothing out of range reaches Float(). Float() then
|
|
95
|
+
# stays the grammar, refusing the "1_" and "1d3" BigDecimal accepts.
|
|
96
|
+
#
|
|
97
|
+
# The zero check reads the mantissa, not BigDecimal#zero?: BigDecimal
|
|
98
|
+
# itself rounds "1e-999999999999999999999" to an exact zero.
|
|
99
|
+
def strict_float(text)
|
|
100
|
+
float = BigDecimal(text).to_f
|
|
101
|
+
return DROP unless float.finite? && (float.nonzero? || !text.match?(/\A[^e]*[1-9]/i))
|
|
102
|
+
|
|
103
|
+
Float(text)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run a parse that raises on bad input (Date/DateTime iso8601, BigDecimal,
|
|
107
|
+
# Integer, Float) and turn the failure into DROP — the keyword default then
|
|
108
|
+
# applies rather than a fabricated value. ::ArgumentError covers
|
|
109
|
+
# BigDecimal("abc") and a blank/garbage string; ::Date::Error (an
|
|
110
|
+
# ArgumentError subclass) covers the iso8601 parsers; ::TypeError covers a
|
|
111
|
+
# nil slipping through; ::FloatDomainError covers BigDecimal under an
|
|
112
|
+
# app's BigDecimal.mode exception flags ("Infinity", "1e400").
|
|
88
113
|
#
|
|
89
114
|
# MUST be top-level-qualified (::): the phlex gem defines Phlex::Argument
|
|
90
115
|
# Error (a subclass of the stdlib one), and this file is nested under
|
|
@@ -93,7 +118,7 @@ module Phlex
|
|
|
93
118
|
# parse failure would escape as a 500.
|
|
94
119
|
def parse_or_drop
|
|
95
120
|
yield
|
|
96
|
-
rescue ::ArgumentError, ::TypeError
|
|
121
|
+
rescue ::ArgumentError, ::TypeError, ::FloatDomainError
|
|
97
122
|
DROP
|
|
98
123
|
end
|
|
99
124
|
|
|
@@ -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
|
-
|
|
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"
|
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.
|
|
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
|