phlex-reactive 0.9.2 → 0.9.4
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 +213 -0
- data/README.md +333 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +651 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +240 -0
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
|
@@ -28,6 +28,23 @@ module Phlex
|
|
|
28
28
|
Phlex::Reactive.current_connection_id
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Manually satisfy the verify_authorized guard (issue #168) for a bespoke
|
|
32
|
+
# authorization check the interceptor can't see — a hand-rolled policy, a
|
|
33
|
+
# feature flag, an ownership comparison that doesn't go through one of
|
|
34
|
+
# Phlex::Reactive.authorization_methods:
|
|
35
|
+
#
|
|
36
|
+
# def publish
|
|
37
|
+
# raise NotAllowed unless @post.author == Current.user
|
|
38
|
+
# mark_authorized!
|
|
39
|
+
# @post.update!(published: true)
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# Call it only AFTER your check passes — it asserts "I have authorized
|
|
43
|
+
# this action." A no-op when verify_authorized is off.
|
|
44
|
+
def mark_authorized!
|
|
45
|
+
Phlex::Reactive::Authorization.mark!
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
# Subject-bound reply builder — the preferred way to control an action's
|
|
32
49
|
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
33
50
|
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
@@ -399,6 +416,162 @@ module Phlex
|
|
|
399
416
|
span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
|
|
400
417
|
end
|
|
401
418
|
|
|
419
|
+
# Value-conditional visibility (issue #161) — the x-show / data-show /
|
|
420
|
+
# wire:show equivalent, entirely client-side. Spread onto the element to
|
|
421
|
+
# show/hide; it declares which OWNED field controls it plus ONE literal
|
|
422
|
+
# predicate, and the generic controller toggles the `hidden` attribute
|
|
423
|
+
# from the field's CURRENT value on every input/change — no round trip,
|
|
424
|
+
# no token, no bespoke Stimulus controller:
|
|
425
|
+
#
|
|
426
|
+
# div(**reactive_show(:mode, not: "off")) { "details" }
|
|
427
|
+
# div(**reactive_show(:kind, equals: "premium")) { "premium panel" }
|
|
428
|
+
# div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }
|
|
429
|
+
# div(**reactive_show(:gift, equals: true)) { "gift message" }
|
|
430
|
+
#
|
|
431
|
+
# Exactly one predicate — equals:, not:, or in: (a list) — and every
|
|
432
|
+
# value is STRINGIFIED for a literal match against the field's value
|
|
433
|
+
# (a checkbox compares its checked state as "true"/"false", so
|
|
434
|
+
# `equals: true` is the checkbox form; a radio group reads the checked
|
|
435
|
+
# radio's value). This is a DECLARED LITERAL MATCH, never an expression
|
|
436
|
+
# — there is no eval surface (default-deny, like the op vocabulary).
|
|
437
|
+
#
|
|
438
|
+
# Scope: presentational only, strictly less powerful than the js ops —
|
|
439
|
+
# it reads an owned field (#15 ownership) and toggles `hidden` on an
|
|
440
|
+
# owned element. The client seeds visibility at connect and reconciles
|
|
441
|
+
# after a morph; render the initial `hidden:` yourself (from the same
|
|
442
|
+
# server state that renders the field) to avoid a first-paint flash.
|
|
443
|
+
# Extra attrs deep-merge over the binding (mix), like reactive_field.
|
|
444
|
+
def reactive_show(field, **options)
|
|
445
|
+
predicates = options.slice(*SHOW_PREDICATE_KEYS)
|
|
446
|
+
attrs = options.except(*SHOW_PREDICATE_KEYS)
|
|
447
|
+
unless predicates.size == 1
|
|
448
|
+
raise ArgumentError,
|
|
449
|
+
"reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, or " \
|
|
450
|
+
"in: — got #{predicates.keys.inspect}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
|
|
454
|
+
data = { reactive_show_field: field.to_s }
|
|
455
|
+
data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value
|
|
456
|
+
|
|
457
|
+
mix({ data: }, attrs)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Client-side option filtering for the searchable combobox (issue #163)
|
|
461
|
+
# — the "preload + type to narrow" half of #72's keyboard nav, entirely
|
|
462
|
+
# client-side. Spread onto the ROOT (mix with reactive_root); it names
|
|
463
|
+
# the input whose value drives the filter and the option elements to
|
|
464
|
+
# show/hide, and the generic controller toggles `hidden` on every
|
|
465
|
+
# keystroke by substring-matching each option's haystack — no round
|
|
466
|
+
# trip, no token, no bespoke per-feature controller:
|
|
467
|
+
#
|
|
468
|
+
# div(**mix(reactive_root, reactive_filter(
|
|
469
|
+
# input: "#exercise-search",
|
|
470
|
+
# option: "[role=option]",
|
|
471
|
+
# group: "[data-filter-group]", # optional: collapse empty group headers
|
|
472
|
+
# empty: "#no-matches" # optional: reveal when 0 match
|
|
473
|
+
# ))) { … }
|
|
474
|
+
#
|
|
475
|
+
# Each option's haystack is its `data-reactive-filter-text` attribute
|
|
476
|
+
# (server-rendered — pack in synonyms/categories), falling back to the
|
|
477
|
+
# option's own text. Matching is a case-folded substring test — a
|
|
478
|
+
# DECLARED literal match, never an expression (no eval surface, the
|
|
479
|
+
# reactive_show posture). `group:` hides any group element whose every
|
|
480
|
+
# contained option is hidden; `empty:` reveals the no-matches node when
|
|
481
|
+
# 0 options are visible. The client seeds at connect and re-syncs after
|
|
482
|
+
# a morph; selectors resolve WITHIN this root only (#15 ownership).
|
|
483
|
+
#
|
|
484
|
+
# Filtering composes with reactive_listnav (Arrow/Enter/Escape skip
|
|
485
|
+
# hidden options) and each option's own on(:select, …) trigger —
|
|
486
|
+
# selection still round-trips as a signed action; only FILTERING is
|
|
487
|
+
# local. Blank selectors raise: a dead binding must fail at render.
|
|
488
|
+
def reactive_filter(input:, option:, group: nil, empty: nil)
|
|
489
|
+
data = {
|
|
490
|
+
reactive_filter_input: filter_selector!(:input, input),
|
|
491
|
+
reactive_filter_option: filter_selector!(:option, option)
|
|
492
|
+
}
|
|
493
|
+
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
494
|
+
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
495
|
+
|
|
496
|
+
{ data: }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# STANDALONE combobox keyboard navigation (issue #163) — the same
|
|
500
|
+
# Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the
|
|
501
|
+
# dispatch descriptor. A preload-and-filter combobox input fires NO
|
|
502
|
+
# action (filtering is pure client), so it can't carry on(); spread
|
|
503
|
+
# this onto the input instead:
|
|
504
|
+
#
|
|
505
|
+
# input(id: "search", type: "search", **reactive_listnav("[role=option]"))
|
|
506
|
+
#
|
|
507
|
+
# Arrow keys move the client-side highlight among the (visible) options,
|
|
508
|
+
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
509
|
+
# (selection stays a signed action), Escape clears. Combine with other
|
|
510
|
+
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
511
|
+
def reactive_listnav(option_selector)
|
|
512
|
+
{
|
|
513
|
+
data: {
|
|
514
|
+
action: LISTNAV_ACTIONS.join(" "),
|
|
515
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
521
|
+
# parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
|
|
522
|
+
# is root-scoped by design (#15), so it can't express "this control
|
|
523
|
+
# drives elements ELSEWHERE on the page" — a nav tab, a panel in another
|
|
524
|
+
# tab pane, a sidebar note. reactive_show_targets is the declared,
|
|
525
|
+
# id-allowlisted escape: the component that OWNS the field declares
|
|
526
|
+
# which outside ids it governs. Spread it on the ROOT (mix alongside
|
|
527
|
+
# reactive_root — the client reads it off the controller element):
|
|
528
|
+
#
|
|
529
|
+
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
530
|
+
# "#advanced-tab" => { equals: "advanced" },
|
|
531
|
+
# "#advanced-panel" => { equals: "advanced" },
|
|
532
|
+
# "#basic-note" => { not: "advanced" })))
|
|
533
|
+
#
|
|
534
|
+
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
535
|
+
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
536
|
+
# only, enforced here at declare time AND warn-and-skipped by the client
|
|
537
|
+
# interpreter (two-sided default-deny); the predicate is the same
|
|
538
|
+
# literal-only reactive_show vocabulary (exactly one of equals:/not:/
|
|
539
|
+
# in:, no expressions); and the toggle is `hidden` only — no innerHTML,
|
|
540
|
+
# no attribute freedom. The FIELD read stays owned (#15): you can only
|
|
541
|
+
# drive outside visibility from a field this root owns. A target id not
|
|
542
|
+
# on the page is silently skipped (an unrendered tab pane is normal).
|
|
543
|
+
#
|
|
544
|
+
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
545
|
+
# values, so a second call's JSON would concatenate into an unparseable
|
|
546
|
+
# attr and the client would drop BOTH maps (it warns when that
|
|
547
|
+
# happens). Several fields therefore go in ONE call via the hash form:
|
|
548
|
+
#
|
|
549
|
+
# reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
|
|
550
|
+
# kind: { "#premium-note" => { not: "basic" } })
|
|
551
|
+
def reactive_show_targets(field, targets = nil)
|
|
552
|
+
field_maps = targets.nil? ? field : { field => targets }
|
|
553
|
+
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
554
|
+
raise ArgumentError,
|
|
555
|
+
"reactive_show_targets needs at least one target " \
|
|
556
|
+
"(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
normalized = field_maps.to_h do |name, map|
|
|
560
|
+
# Catch the forgotten-field-name misuse — reactive_show_targets(
|
|
561
|
+
# "#id" => {…}) — before the per-target validation turns it into a
|
|
562
|
+
# baffling "predicate" error.
|
|
563
|
+
if name.to_s.start_with?("#")
|
|
564
|
+
raise ArgumentError,
|
|
565
|
+
"reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
|
|
566
|
+
"name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
[name.to_s, normalize_show_target_map(name, map)]
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
{ data: { reactive_show_targets: normalized.to_json } }
|
|
573
|
+
end
|
|
574
|
+
|
|
402
575
|
# Scoped busy indicator (issue #99). Marks an element so the generic
|
|
403
576
|
# controller toggles `data-reactive-busy` on it ONLY while `action` is in
|
|
404
577
|
# flight — the scoped sibling of the always-on `data-reactive-busy` the
|
|
@@ -485,6 +658,13 @@ module Phlex
|
|
|
485
658
|
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
486
659
|
end
|
|
487
660
|
|
|
661
|
+
# The declared reactive_show predicates (issue #161): a literal match on
|
|
662
|
+
# the controlling field's current value. Exactly one per binding —
|
|
663
|
+
# enforced loudly at render (a dead binding must not no-op in the
|
|
664
|
+
# browser). `not`/`in` are Ruby keywords, so they arrive via **options
|
|
665
|
+
# rather than named kwargs.
|
|
666
|
+
SHOW_PREDICATE_KEYS = %i[equals not in].freeze
|
|
667
|
+
|
|
488
668
|
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
489
669
|
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
490
670
|
# at build time in optimistic_hint_json (default-deny — a dead hint fails
|
|
@@ -494,6 +674,66 @@ module Phlex
|
|
|
494
674
|
|
|
495
675
|
private
|
|
496
676
|
|
|
677
|
+
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
678
|
+
# stringified (issue #163). A blank selector is a dead binding — the
|
|
679
|
+
# client would silently match nothing — so it fails loudly at render,
|
|
680
|
+
# like reactive_show's predicate validation.
|
|
681
|
+
def filter_selector!(name, value)
|
|
682
|
+
selector = value.to_s
|
|
683
|
+
if selector.strip.empty?
|
|
684
|
+
raise ArgumentError,
|
|
685
|
+
"reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
selector
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Normalize + validate ONE field's target map (issue #164): each key a
|
|
692
|
+
# single id selector (loud raise — the declare-time half of the
|
|
693
|
+
# two-sided default-deny), each value one literal predicate. Shared by
|
|
694
|
+
# both reactive_show_targets call forms.
|
|
695
|
+
def normalize_show_target_map(field, targets)
|
|
696
|
+
unless targets.is_a?(Hash) && targets.any?
|
|
697
|
+
raise ArgumentError,
|
|
698
|
+
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
699
|
+
"(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
targets.to_h do |selector, predicate|
|
|
703
|
+
selector = selector.to_s
|
|
704
|
+
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
705
|
+
raise ArgumentError,
|
|
706
|
+
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
707
|
+
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
|
|
711
|
+
[selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Validate ONE declared literal show predicate (issues #161/#164) and
|
|
716
|
+
# return its wire form — { "equals" => "v" } / { "not" => "v" } /
|
|
717
|
+
# { "in" => ["a", …] } (a real array: reactive_show JSON-encodes it into
|
|
718
|
+
# its own attr; the reactive_show_targets map embeds it directly).
|
|
719
|
+
# Shared by both helpers so the vocabulary and the loud validation can
|
|
720
|
+
# never drift. `context` names the call site in the error.
|
|
721
|
+
def normalize_show_predicate(predicates, context)
|
|
722
|
+
unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
|
|
723
|
+
raise ArgumentError,
|
|
724
|
+
"#{context} needs exactly one predicate — equals:, not:, or in: — " \
|
|
725
|
+
"got #{predicates.keys.inspect}"
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
key, value = predicates.first
|
|
729
|
+
return { key.to_s => value.to_s } unless key == :in
|
|
730
|
+
|
|
731
|
+
list = Array(value).map(&:to_s)
|
|
732
|
+
raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
|
|
733
|
+
|
|
734
|
+
{ "in" => list }
|
|
735
|
+
end
|
|
736
|
+
|
|
497
737
|
# True when the hint declares checked: :keep — the click-bound
|
|
498
738
|
# checkbox/radio case that must SKIP the forced type="button" so the native
|
|
499
739
|
# control (and its native flip) survives (issue #98). Accepts symbol or
|
|
@@ -55,6 +55,15 @@ module Phlex
|
|
|
55
55
|
# declared, the token carries just {c} — enough to mount, but with no
|
|
56
56
|
# identity to round-trip, so declare reactive_state for a draft you sync.
|
|
57
57
|
def reactive_token
|
|
58
|
+
Phlex::Reactive.sign(reactive_identity_payload)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The UNSIGNED identity payload — extracted from reactive_token (issue
|
|
62
|
+
# #165) so the defer machinery mints its purpose-scoped defer tokens
|
|
63
|
+
# from the exact same shape and the two token families can never drift.
|
|
64
|
+
# Still THE hot path (reactive_token calls it on every render); the
|
|
65
|
+
# split adds one method dispatch, verified flat by the token bench.
|
|
66
|
+
def reactive_identity_payload
|
|
58
67
|
klass = self.class
|
|
59
68
|
payload = { "c" => klass.name }
|
|
60
69
|
|
|
@@ -75,7 +84,7 @@ module Phlex
|
|
|
75
84
|
payload["s"] = state
|
|
76
85
|
end
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
payload
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
# A record's gid is signable unless it is an unsaved AR-like draft
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Lazy initial mount (issue #165): a `reactive_lazy` component's FIRST
|
|
7
|
+
# (page-embedded) render emits a placeholder SHELL — the root id, the
|
|
8
|
+
# generic controller, the pending markers, and a defer token ON the root
|
|
9
|
+
# — and the client fetches the real content through the same defer
|
|
10
|
+
# machinery on connect (the Livewire #[Lazy] shape).
|
|
11
|
+
#
|
|
12
|
+
# Lazy applies ONLY to the initial mount. Every render that goes through
|
|
13
|
+
# the reactive machinery — an action reply's self-replace, a broadcast,
|
|
14
|
+
# the defer endpoint/job, the class stream builders — runs inside
|
|
15
|
+
# Defer.with_real_render, so it renders the REAL template. Anything else
|
|
16
|
+
# would make an action on a lazy component cost two round trips.
|
|
17
|
+
module Lazy
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Phlex 2's render hook: the template runs inside the block. Overridden
|
|
23
|
+
# (not yield-then-decorate) so the lazy shell can REPLACE the template
|
|
24
|
+
# entirely on the initial mount. A component's own around_template
|
|
25
|
+
# override composes via normal method lookup as long as it calls super.
|
|
26
|
+
def around_template(&)
|
|
27
|
+
klass = self.class
|
|
28
|
+
if klass.respond_to?(:reactive_lazy?) && klass.reactive_lazy? && !Phlex::Reactive::Defer.real_render?
|
|
29
|
+
render_defer_shell
|
|
30
|
+
else
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The shell: owns the component's id (the arrival replaces it by that
|
|
36
|
+
# id), mounts the generic controller, and carries the defer token as a
|
|
37
|
+
# ROOT attribute — the controller's connect() probes it and enters the
|
|
38
|
+
# same module-level fetch path a reply directive uses. Pending markers
|
|
39
|
+
# + the .reactive-defer-placeholder class are the CSS hooks.
|
|
40
|
+
def render_defer_shell
|
|
41
|
+
public_send(
|
|
42
|
+
self.class.reactive_lazy_tag,
|
|
43
|
+
id:,
|
|
44
|
+
class: "reactive-defer-placeholder",
|
|
45
|
+
aria: { busy: "true" },
|
|
46
|
+
data: {
|
|
47
|
+
controller: "reactive",
|
|
48
|
+
reactive_defer_pending: "true",
|
|
49
|
+
# UNBOUND (issue #165 security): a lazy shell renders during the
|
|
50
|
+
# page render — on a fresh visit the session doesn't exist yet, so
|
|
51
|
+
# the token can't be actor-bound (it would 400 at the endpoint,
|
|
52
|
+
# which by then IS bound). It lives in the actor's own page (a
|
|
53
|
+
# small leak surface); the TTL + `authorize!` are its bound. Only
|
|
54
|
+
# reply.defer tokens (in action responses that can transit proxies)
|
|
55
|
+
# are actor-bound.
|
|
56
|
+
reactive_defer_token: Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true)
|
|
57
|
+
}
|
|
58
|
+
) { render_deferred_placeholder_content }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Same content contract as reply.defer's placeholder: true — the
|
|
62
|
+
# component's deferred_placeholder returns a Phlex component instance
|
|
63
|
+
# (rendered), an html_safe String (raw), or a plain String (escaped
|
|
64
|
+
# text — data, not markup). No method / nil → the empty shell.
|
|
65
|
+
def render_deferred_placeholder_content
|
|
66
|
+
return unless respond_to?(:deferred_placeholder, true)
|
|
67
|
+
|
|
68
|
+
value = send(:deferred_placeholder)
|
|
69
|
+
case value
|
|
70
|
+
when nil then nil
|
|
71
|
+
when ::Phlex::SGML then render(value)
|
|
72
|
+
else
|
|
73
|
+
value.html_safe? ? raw(value) : plain(value.to_s)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -50,7 +50,13 @@ module Phlex
|
|
|
50
50
|
state_keys: :@reactive_own_state_keys,
|
|
51
51
|
collections: :@reactive_own_collections,
|
|
52
52
|
computes: :@reactive_own_computes,
|
|
53
|
-
record_key: :@reactive_own_record_key
|
|
53
|
+
record_key: :@reactive_own_record_key,
|
|
54
|
+
# Deferred reply segments — reactive_lazy (issue #165).
|
|
55
|
+
lazy: :@reactive_own_lazy,
|
|
56
|
+
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
57
|
+
# WHOLE component) plus a list of named actions.
|
|
58
|
+
skip_all: :@reactive_own_skip_all,
|
|
59
|
+
skip_actions: :@reactive_own_skip_actions
|
|
54
60
|
}.freeze
|
|
55
61
|
|
|
56
62
|
# The identity-side hot-path memos swept by bump! (see the contract
|