phlex-reactive 0.13.4 → 0.14.1
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 +1079 -630
- data/README.md +612 -17
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3706 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +188 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +47 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +27 -7
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- metadata +38 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
class Doctor
|
|
6
|
+
# Falcon serves each request as a fiber on one thread (issue #321). Under
|
|
7
|
+
# Rails' default `config.active_support.isolation_level = :thread`, all
|
|
8
|
+
# thread-keyed state — CurrentAttributes, IsolatedExecutionState, the lock
|
|
9
|
+
# on the connection transactional tests pin, thread variables —
|
|
10
|
+
# is shared by every request on that thread. phlex-reactive's own
|
|
11
|
+
# request state is fiber-local and unaffected; the app around it is not.
|
|
12
|
+
#
|
|
13
|
+
# Detection never loads a server: it reads the bundle (Gem.loaded_specs)
|
|
14
|
+
# and which server constants are already defined.
|
|
15
|
+
module FiberIsolation
|
|
16
|
+
# Server gem => the constant it defines once loaded.
|
|
17
|
+
SERVERS = {
|
|
18
|
+
"falcon" => "Falcon",
|
|
19
|
+
"puma" => "Puma",
|
|
20
|
+
"unicorn" => "Unicorn",
|
|
21
|
+
"pitchfork" => "Pitchfork",
|
|
22
|
+
"passenger" => "PhusionPassenger",
|
|
23
|
+
"thin" => "Thin",
|
|
24
|
+
"iodine" => "Iodine"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
FIBER_FIX = "Under :thread, thread-keyed state (CurrentAttributes, IsolatedExecutionState, " \
|
|
28
|
+
"thread variables) is shared by every request Falcon serves on a thread. " \
|
|
29
|
+
"Set in config/application.rb:\n config.active_support.isolation_level = :fiber"
|
|
30
|
+
|
|
31
|
+
def self.included(base)
|
|
32
|
+
base.extend(ClassMethods)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module ClassMethods
|
|
36
|
+
# The server gem serving the app: Falcon when it is the only one
|
|
37
|
+
# loaded, else the only one bundled. A lone loaded non-Falcon server
|
|
38
|
+
# only counts when Falcon isn't bundled — the doctor runs as a rake
|
|
39
|
+
# task, where "loaded" is whatever Bundler.require pulled in (Rails'
|
|
40
|
+
# default `gem "puma"` beside `gem "falcon", require: false`). nil
|
|
41
|
+
# when Falcon is present beside another server and neither is
|
|
42
|
+
# singled out; :none when no Falcon is anywhere.
|
|
43
|
+
def detect_server(bundled: bundled_servers, loaded: loaded_servers)
|
|
44
|
+
return loaded.first if loaded.one? && (loaded.first == "falcon" || bundled.exclude?("falcon"))
|
|
45
|
+
return bundled.first if bundled.one?
|
|
46
|
+
|
|
47
|
+
(bundled | loaded).include?("falcon") ? nil : :none
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bundled_servers
|
|
51
|
+
SERVERS.keys.select { Gem.loaded_specs.key?(it) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def loaded_servers
|
|
55
|
+
SERVERS.select { |_gem, const| Object.const_defined?(const) }.keys
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def isolation_level
|
|
59
|
+
ActiveSupport::IsolatedExecutionState.isolation_level
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fiber_isolation_check(server: Doctor.detect_server, isolation: Doctor.isolation_level)
|
|
64
|
+
if isolation == :fiber
|
|
65
|
+
Check.new(:ok, "config.active_support.isolation_level is :fiber", name: :fiber_isolation)
|
|
66
|
+
elsif server == "falcon"
|
|
67
|
+
Check.new(:fail, "Falcon serves requests as fibers, but config.active_support.isolation_level " \
|
|
68
|
+
"is :#{isolation}", name: :fiber_isolation, fix: FIBER_FIX)
|
|
69
|
+
elsif server.nil?
|
|
70
|
+
Check.new(:unknown, "Falcon is in the bundle beside another server; could not tell which serves " \
|
|
71
|
+
"the app (isolation_level is :#{isolation})", name: :fiber_isolation,
|
|
72
|
+
fix: "If Falcon serves the app: #{FIBER_FIX}")
|
|
73
|
+
else
|
|
74
|
+
Check.new(:ok, "#{server_label(server)}; :#{isolation} isolation is fine", name: :fiber_isolation)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def server_label(server)
|
|
81
|
+
server == :none ? "no Falcon in the bundle" : "#{server} does not serve requests as fibers"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -16,6 +16,8 @@ module Phlex
|
|
|
16
16
|
# default-deny boundary; the worst it does is a throwaway sign→verify round
|
|
17
17
|
# trip and (for the component checks) iterate the loaded Streamable registry.
|
|
18
18
|
class Doctor
|
|
19
|
+
include FiberIsolation
|
|
20
|
+
|
|
19
21
|
# The result of one check: a status (:ok/:fail/:unknown), a human message,
|
|
20
22
|
# and (on anything but :ok) a fix line telling the adopter what to do. A
|
|
21
23
|
# plain value object (not Data) so it takes positional status/message plus
|
|
@@ -70,14 +72,19 @@ module Phlex
|
|
|
70
72
|
[
|
|
71
73
|
route_check,
|
|
72
74
|
defer_route_check,
|
|
75
|
+
fragment_route_check,
|
|
76
|
+
fragment_path_meta_check,
|
|
73
77
|
stimulus_check,
|
|
74
78
|
csrf_check,
|
|
75
79
|
verifier_check,
|
|
76
80
|
base_controller_check,
|
|
81
|
+
fiber_isolation_check,
|
|
77
82
|
action_check(components),
|
|
78
83
|
id_check(components),
|
|
79
|
-
authorization_check(components)
|
|
80
|
-
|
|
84
|
+
authorization_check(components),
|
|
85
|
+
dormant_check(components),
|
|
86
|
+
*early_pin_check(components)
|
|
87
|
+
].compact
|
|
81
88
|
end
|
|
82
89
|
|
|
83
90
|
# --- individual checks ------------------------------------------------
|
|
@@ -97,6 +104,45 @@ module Phlex
|
|
|
97
104
|
path_check(Phlex::Reactive.defer_path, :defer_route, "Phlex::Reactive.defer_path")
|
|
98
105
|
end
|
|
99
106
|
|
|
107
|
+
# The cacheable-fragment GET endpoint (issue #277): a host GET catch-all
|
|
108
|
+
# that shadows it makes every `reactive_lazy cache:` fetch load the host's
|
|
109
|
+
# fallback page instead of the fragment.
|
|
110
|
+
def fragment_route_check
|
|
111
|
+
path = "#{Phlex::Reactive.fragment_path}/:id"
|
|
112
|
+
if Phlex::Reactive.fragment_route_ok?
|
|
113
|
+
Check.new(:ok, "GET #{path} routes to phlex/reactive/fragments", name: :fragment_route)
|
|
114
|
+
else
|
|
115
|
+
Check.new(:fail, "GET #{path} does not resolve to phlex/reactive/fragments", name: :fragment_route,
|
|
116
|
+
fix: "A host catch-all route (get \"*path\", ...) likely shadows it. Exempt " \
|
|
117
|
+
"#{Phlex::Reactive.fragment_path.delete_prefix("/")} from the catch-all, or set " \
|
|
118
|
+
"Phlex::Reactive.fragment_path to an unshadowed path.")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A custom fragment_path the client was never told about (issue #277): the
|
|
123
|
+
# client only fetches a fragment URL under the path it knows — the meta
|
|
124
|
+
# tag, else the default — so every `reactive_lazy cache:` shell would be
|
|
125
|
+
# refused (reactive:error, the shell never loads). Only checked when the
|
|
126
|
+
# path was changed; nil (no check) otherwise.
|
|
127
|
+
def fragment_path_meta_check
|
|
128
|
+
path = Phlex::Reactive.fragment_path
|
|
129
|
+
return if path == "/reactive/fragment"
|
|
130
|
+
|
|
131
|
+
# A file must name the meta AND supply this path — literally, or from
|
|
132
|
+
# the setting (`Phlex::Reactive.fragment_path`). A tag left behind with
|
|
133
|
+
# an old path is as broken as none. Whether it sits in <head> (the only
|
|
134
|
+
# place the client reads it) can't be told from source; the fix says so.
|
|
135
|
+
if layout_references?("phlex-reactive-fragment-path") { it.include?(path) || it.include?("fragment_path") }
|
|
136
|
+
Check.new(:ok, "phlex-reactive-fragment-path meta found for #{path} (it must be in <head>)",
|
|
137
|
+
name: :fragment_path_meta)
|
|
138
|
+
else
|
|
139
|
+
Check.new(:fail, "Phlex::Reactive.fragment_path is #{path} but no layout renders its meta tag with that path",
|
|
140
|
+
name: :fragment_path_meta,
|
|
141
|
+
fix: "Add <meta name=\"phlex-reactive-fragment-path\" content=\"#{path}\"> to your layout's " \
|
|
142
|
+
"<head> — the client refuses a fragment URL outside the path it knows.")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
100
146
|
# Shared body for the two endpoint-route checks: both POST to the gem's
|
|
101
147
|
# ActionsController, so action_route_ok? answers for either path.
|
|
102
148
|
def path_check(path, name, setting)
|
|
@@ -212,6 +258,42 @@ module Phlex
|
|
|
212
258
|
"`skip_verify_authorized`. See the Debugging & tooling docs page.")
|
|
213
259
|
end
|
|
214
260
|
|
|
261
|
+
# Dormant roots (issue #274): how many components declare `reactive_dormant`
|
|
262
|
+
# and whether a Stimulus entrypoint imports phlex/reactive/early — the
|
|
263
|
+
# module that wakes them (without it a dormant root never mounts). No line
|
|
264
|
+
# at all when nothing is dormant. ADVISORY when the import isn't found: it
|
|
265
|
+
# may live in a file the doctor doesn't scan. A per-render
|
|
266
|
+
# reactive_root(dormant: true) is invisible here (it isn't a declaration).
|
|
267
|
+
def dormant_check(components, early: imports_early?)
|
|
268
|
+
dormant = dormant_components(components)
|
|
269
|
+
return if dormant.empty?
|
|
270
|
+
|
|
271
|
+
label = "#{dormant.size} dormant #{dormant.one? ? "component" : "components"} (#{dormant.map(&:name).join(", ")})"
|
|
272
|
+
return Check.new(:ok, "#{label}; phlex/reactive/early is imported#{early_location(early)}", name: :dormant) if early
|
|
273
|
+
|
|
274
|
+
Check.new(:unknown, "#{label}, but no import of phlex/reactive/early was found", name: :dormant,
|
|
275
|
+
fix: "A dormant root is woken by that module — without it the root never mounts. Add to " \
|
|
276
|
+
"your entrypoint, before any controller loads:\n import \"phlex/reactive/early\"")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Every import map the app registers must pin phlex/reactive/early when a
|
|
280
|
+
# dormant root exists (issue #307): a map without the pin cannot resolve
|
|
281
|
+
# the specifier, so the import fails on the pages that map serves. One
|
|
282
|
+
# advisory check per map lacking the pin; none when every map has it.
|
|
283
|
+
# Maps: Rails.application.importmap, or Phlex::Reactive.importmaps (a
|
|
284
|
+
# callable returning { name => Importmap::Map }) for an app with its own.
|
|
285
|
+
def early_pin_check(components)
|
|
286
|
+
return [] if dormant_components(components).empty?
|
|
287
|
+
|
|
288
|
+
import_maps.filter_map do |name, map|
|
|
289
|
+
next if map_pins_early?(map)
|
|
290
|
+
|
|
291
|
+
Check.new(:unknown, "import map \"#{name}\" does not pin phlex/reactive/early", name: :early_pin,
|
|
292
|
+
fix: "A dormant root rendered on a page served by this map cannot load the module. Add to " \
|
|
293
|
+
"the map's file:\n pin \"phlex/reactive/early\", to: \"phlex/reactive/early.min.js\", preload: true")
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
215
297
|
# --- rendering --------------------------------------------------------
|
|
216
298
|
|
|
217
299
|
# The full plain-text report: a line per check, plus an indented fix under
|
|
@@ -236,6 +318,27 @@ module Phlex
|
|
|
236
318
|
"phlex/reactive/actions"
|
|
237
319
|
end
|
|
238
320
|
|
|
321
|
+
STIMULUS_ENTRYPOINTS = %w[
|
|
322
|
+
app/javascript/controllers/index.js
|
|
323
|
+
app/javascript/controllers/application.js
|
|
324
|
+
app/javascript/application.js
|
|
325
|
+
].freeze
|
|
326
|
+
EARLY_IMPORT = %r{import\s+["']phlex/reactive/early["']}
|
|
327
|
+
# Closed comments, which may span lines: HTML, JS block and ERB.
|
|
328
|
+
BLOCK_COMMENT = %r{<!--.*?-->|/\*.*?\*/|<%#.*?%>}m
|
|
329
|
+
# What precedes the import on its line when it is commented out: a //
|
|
330
|
+
# (not the one in a URL), an unclosed /*, or a block-comment's leading *.
|
|
331
|
+
COMMENT_BEFORE = %r{(?<!:)//|/\*|\A\s*\*}
|
|
332
|
+
|
|
333
|
+
# Is there an `import "phlex/reactive/early"` in `source` that actually
|
|
334
|
+
# runs? Closed comments are dropped first, then each line is read, so a
|
|
335
|
+
# commented-out import doesn't count.
|
|
336
|
+
def self.imports_early_source?(source)
|
|
337
|
+
source.gsub(BLOCK_COMMENT, "").each_line.any? do
|
|
338
|
+
(match = EARLY_IMPORT.match(it)) && !match.pre_match.match?(COMMENT_BEFORE)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
239
342
|
private
|
|
240
343
|
|
|
241
344
|
# A one-line tally: how many passed, failed, and are advisory/unknown.
|
|
@@ -340,11 +443,7 @@ module Phlex
|
|
|
340
443
|
# small/importmap app often registers inline in <head> rather than in a
|
|
341
444
|
# dedicated entrypoint file. Only existing files are returned.
|
|
342
445
|
def stimulus_registration_files
|
|
343
|
-
candidates =
|
|
344
|
-
app/javascript/controllers/index.js
|
|
345
|
-
app/javascript/controllers/application.js
|
|
346
|
-
app/javascript/application.js
|
|
347
|
-
].map { app_path(it) }
|
|
446
|
+
candidates = STIMULUS_ENTRYPOINTS.map { app_path(it) }
|
|
348
447
|
candidates += ::Dir.glob(app_path("app/views/layouts/**/*.erb"))
|
|
349
448
|
candidates.select { File.exist?(it) }
|
|
350
449
|
end
|
|
@@ -355,6 +454,58 @@ module Phlex
|
|
|
355
454
|
false
|
|
356
455
|
end
|
|
357
456
|
|
|
457
|
+
# The relative path of the first file that imports phlex/reactive/early,
|
|
458
|
+
# or false. The Stimulus registration files go first, then every other app
|
|
459
|
+
# JavaScript entry (vendor excluded) and inline module scripts in views and
|
|
460
|
+
# components (issue #307).
|
|
461
|
+
def imports_early?
|
|
462
|
+
(stimulus_registration_files | early_import_candidates).each do
|
|
463
|
+
return relative(it) if Doctor.imports_early_source?(early_source(it))
|
|
464
|
+
rescue StandardError
|
|
465
|
+
next
|
|
466
|
+
end
|
|
467
|
+
false
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# A Ruby file's `# …` comment lines are not code (a layout that mentions
|
|
471
|
+
# the import in a comment must not pass the check).
|
|
472
|
+
def early_source(path)
|
|
473
|
+
source = File.read(path)
|
|
474
|
+
path.end_with?(".rb") ? source.lines.grep_v(/\A\s*#/).join : source
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def early_import_candidates
|
|
478
|
+
globs = %w[app/javascript/**/*.js app/views/**/*.{rb,erb} app/components/**/*.{rb,erb}].map { app_path(it) }
|
|
479
|
+
::Dir.glob(globs).reject { it.include?("/vendor/") }.sort
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# " (path)" when the import sits outside the JavaScript entrypoints the
|
|
483
|
+
# doctor always scanned (those keep their original output).
|
|
484
|
+
def early_location(early)
|
|
485
|
+
return "" unless early.is_a?(String)
|
|
486
|
+
return "" if STIMULUS_ENTRYPOINTS.include?(early)
|
|
487
|
+
|
|
488
|
+
" (#{early})"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def dormant_components(components)
|
|
492
|
+
components.select { it.respond_to?(:reactive_dormant?) && it.reactive_dormant? }
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def import_maps
|
|
496
|
+
default = ::Rails.application.respond_to?(:importmap) ? ::Rails.application.importmap : nil
|
|
497
|
+
maps = default ? { "application" => default } : {}
|
|
498
|
+
maps.merge(Phlex::Reactive.importmaps&.call || {})
|
|
499
|
+
rescue StandardError
|
|
500
|
+
maps || {}
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def map_pins_early?(map)
|
|
504
|
+
map.packages.key?("phlex/reactive/early")
|
|
505
|
+
rescue StandardError
|
|
506
|
+
true
|
|
507
|
+
end
|
|
508
|
+
|
|
358
509
|
# Only meaningful when importmap is in use. True when importmap is present
|
|
359
510
|
# but the reactive_controller pin is absent (the engine pins it, so this is
|
|
360
511
|
# really "someone overrode importmap.rb and dropped the pin").
|
|
@@ -371,6 +522,12 @@ module Phlex
|
|
|
371
522
|
|
|
372
523
|
# Grep ERB layouts AND Phlex layout files for a csrf_meta_tags reference.
|
|
373
524
|
def csrf_meta_referenced?
|
|
525
|
+
layout_references?("csrf_meta_tags")
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Does any ERB view / Phlex view or component mention `needle` (and, with
|
|
529
|
+
# a block, satisfy it for that file's source)?
|
|
530
|
+
def layout_references?(needle)
|
|
374
531
|
globs = %w[
|
|
375
532
|
app/views/**/*.erb
|
|
376
533
|
app/views/**/*.rb
|
|
@@ -379,7 +536,8 @@ module Phlex
|
|
|
379
536
|
].map { app_path(it) }
|
|
380
537
|
|
|
381
538
|
::Dir.glob(globs).any? do
|
|
382
|
-
File.read(it)
|
|
539
|
+
source = File.read(it)
|
|
540
|
+
source.include?(needle) && (!block_given? || yield(source))
|
|
383
541
|
rescue StandardError
|
|
384
542
|
false
|
|
385
543
|
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# Dormant roots (issue #274). A root declared dormant — `reactive_dormant`
|
|
6
|
+
# on the class, or `reactive_root(dormant: true)` — renders
|
|
7
|
+
# data-reactive-dormant="reactive" INSTEAD of data-controller="reactive":
|
|
8
|
+
# the reactive controller is not mounted (and, when the app loads it lazily,
|
|
9
|
+
# not even fetched) until one of the root's triggers fires. The client's
|
|
10
|
+
# phlex/reactive/early module then moves the identifier into data-controller
|
|
11
|
+
# and the trigger is replayed on connect.
|
|
12
|
+
#
|
|
13
|
+
# A render made for the ACTOR's own reply is AWAKE: the reply only exists
|
|
14
|
+
# because that page's controller is loaded and connected, so a dormant
|
|
15
|
+
# replacement would save nothing and cost one more wake. The endpoint runs
|
|
16
|
+
# the action and its reply (and the defer render) inside Dormant.awake;
|
|
17
|
+
# every other render — the page, a stream built outside a reactive request,
|
|
18
|
+
# and a BROADCAST even when fired inside an action (Dormant.asleep) — stays
|
|
19
|
+
# dormant, because its receivers may never have loaded the client.
|
|
20
|
+
module Dormant
|
|
21
|
+
AWAKE_KEY = :phlex_reactive_dormant_awake
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def awake?
|
|
25
|
+
Thread.current[AWAKE_KEY] ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Render dormant roots awake inside the block (fiber-local, restored on
|
|
29
|
+
# exit — a raise included).
|
|
30
|
+
def awake
|
|
31
|
+
previous = Thread.current[AWAKE_KEY]
|
|
32
|
+
Thread.current[AWAKE_KEY] = true
|
|
33
|
+
yield
|
|
34
|
+
ensure
|
|
35
|
+
Thread.current[AWAKE_KEY] = previous
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A reactive_lazy(on: :visible) shell (issue #276) has no trigger
|
|
39
|
+
# descriptor — the CONTROLLER's IntersectionObserver materializes it —
|
|
40
|
+
# so a dormant one could never wake. Refused where it is declared, and
|
|
41
|
+
# (for the other declaration order) where the shell renders.
|
|
42
|
+
def reject_visible_lazy!(klass)
|
|
43
|
+
return unless klass.respond_to?(:reactive_lazy_trigger) && klass.reactive_lazy_trigger&.key?(:visible)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"#{klass}: reactive_dormant cannot be combined with reactive_lazy(on: :visible) — a dormant " \
|
|
47
|
+
"root wakes on a trigger event, and a visibility shell has none (the controller's " \
|
|
48
|
+
"IntersectionObserver loads it). Use reactive_lazy(on: \"an:event\"), or drop reactive_dormant."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The inverse, for a render fired INSIDE an awake block that leaves the
|
|
52
|
+
# actor's page — a broadcast. A plain yield when nothing is awake.
|
|
53
|
+
def asleep
|
|
54
|
+
return yield unless Thread.current[AWAKE_KEY]
|
|
55
|
+
|
|
56
|
+
begin
|
|
57
|
+
Thread.current[AWAKE_KEY] = nil
|
|
58
|
+
yield
|
|
59
|
+
ensure
|
|
60
|
+
Thread.current[AWAKE_KEY] = true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -11,20 +11,39 @@ module Phlex
|
|
|
11
11
|
class Engine < ::Rails::Engine
|
|
12
12
|
isolate_namespace Phlex::Reactive
|
|
13
13
|
|
|
14
|
+
# The client's feature modules (issue #275), each its own file at the
|
|
15
|
+
# bare specifier phlex/reactive/features/<name>. The DEFAULT client
|
|
16
|
+
# (phlex/reactive/reactive_controller) is one file with all of them
|
|
17
|
+
# bundled in and never fetches these. The opt-in phlex/reactive/core
|
|
18
|
+
# imports one only when a root on the page uses it. Listed here once; the
|
|
19
|
+
# precompile list and the importmap pins below are built from it. The
|
|
20
|
+
# build script (scripts/build_client.js), the core's own table and the
|
|
21
|
+
# default entry name the same features —
|
|
22
|
+
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9: the default file 26.1 KB; the core 12.4 KB;
|
|
25
|
+
# persist 3.2, defer 2.8, form 1.0, bindings 5.4, compute 2.1, effects
|
|
26
|
+
# 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
|
|
27
|
+
# is the source of truth and holds a ratchet on each.)
|
|
28
|
+
|
|
14
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
|
15
30
|
# and POST /reactive/defer -> #deferred (the pull-lane defer endpoint,
|
|
16
|
-
# issue #165)
|
|
17
|
-
# .
|
|
31
|
+
# issue #165), and GET /reactive/fragment/:id -> FragmentsController#show
|
|
32
|
+
# (the privately cacheable lazy render, issue #277). Apps can change the
|
|
33
|
+
# paths with Phlex::Reactive.action_path / .defer_path / .fragment_path
|
|
34
|
+
# before boot.
|
|
18
35
|
initializer "phlex_reactive.routes" do
|
|
19
36
|
it.routes.append do
|
|
20
37
|
post Phlex::Reactive.action_path, to: "phlex/reactive/actions#create", as: :phlex_reactive_action
|
|
21
38
|
post Phlex::Reactive.defer_path, to: "phlex/reactive/actions#deferred", as: :phlex_reactive_defer
|
|
39
|
+
get "#{Phlex::Reactive.fragment_path}/:id", to: "phlex/reactive/fragments#show",
|
|
40
|
+
as: :phlex_reactive_fragment
|
|
22
41
|
end
|
|
23
42
|
end
|
|
24
43
|
|
|
25
44
|
# Make the MINIFIED client build available to Propshaft/Sprockets so it can
|
|
26
45
|
# be fingerprinted, served, and pinned in importmap. The browser ships the
|
|
27
|
-
# minified twin (
|
|
46
|
+
# minified twin (about a fifth of the source; `rake build:js`), not
|
|
28
47
|
# the comment-dense source. The .min.js.map is precompiled too so devtools
|
|
29
48
|
# resolves the linked sourcemap back to the readable source on demand.
|
|
30
49
|
initializer "phlex_reactive.assets" do
|
|
@@ -37,6 +56,10 @@ module Phlex
|
|
|
37
56
|
it.config.assets.precompile += %w[
|
|
38
57
|
phlex/reactive/reactive_controller.min.js
|
|
39
58
|
phlex/reactive/reactive_controller.min.js.map
|
|
59
|
+
phlex/reactive/core.min.js
|
|
60
|
+
phlex/reactive/core.min.js.map
|
|
61
|
+
phlex/reactive/early.min.js
|
|
62
|
+
phlex/reactive/early.min.js.map
|
|
40
63
|
phlex/reactive/confirm.min.js
|
|
41
64
|
phlex/reactive/confirm.min.js.map
|
|
42
65
|
phlex/reactive/confirm_predicate.min.js
|
|
@@ -47,6 +70,9 @@ module Phlex
|
|
|
47
70
|
phlex/reactive/inspect.min.js.map
|
|
48
71
|
phlex/reactive/effects.css
|
|
49
72
|
]
|
|
73
|
+
it.config.assets.precompile += CLIENT_FEATURES.flat_map do
|
|
74
|
+
["phlex/reactive/features/#{it}.min.js", "phlex/reactive/features/#{it}.min.js.map"]
|
|
75
|
+
end
|
|
50
76
|
end
|
|
51
77
|
end
|
|
52
78
|
|
|
@@ -55,11 +81,32 @@ module Phlex
|
|
|
55
81
|
# asset pipeline instead (see README).
|
|
56
82
|
initializer "phlex_reactive.importmap", after: "importmap" do
|
|
57
83
|
if defined?(::Importmap::Map) && it.respond_to?(:importmap)
|
|
84
|
+
# The DEFAULT client: one file, the core and every feature bundled.
|
|
58
85
|
it.importmap.pin(
|
|
59
86
|
"phlex/reactive/reactive_controller",
|
|
60
87
|
to: "phlex/reactive/reactive_controller.min.js",
|
|
61
88
|
preload: true
|
|
62
89
|
)
|
|
90
|
+
# The OPT-IN client (issue #275): the controller without its
|
|
91
|
+
# features, which it imports on demand (the feature pins below). An
|
|
92
|
+
# app imports this INSTEAD of reactive_controller, never both. Not
|
|
93
|
+
# preloaded: an app that does not opt in must not fetch it. One that
|
|
94
|
+
# does re-pins it with preload: true, like any entry it imports
|
|
95
|
+
# eagerly.
|
|
96
|
+
it.importmap.pin(
|
|
97
|
+
"phlex/reactive/core",
|
|
98
|
+
to: "phlex/reactive/core.min.js",
|
|
99
|
+
preload: false
|
|
100
|
+
)
|
|
101
|
+
# The early-trigger capture (issue #273): a ~1 KB module the app
|
|
102
|
+
# imports EAGERLY (`import "phlex/reactive/early"`) so a trigger that
|
|
103
|
+
# fires before a lazily loaded controller connects is replayed on
|
|
104
|
+
# connect instead of lost. Preloaded: it must run before the triggers.
|
|
105
|
+
it.importmap.pin(
|
|
106
|
+
"phlex/reactive/early",
|
|
107
|
+
to: "phlex/reactive/early.min.js",
|
|
108
|
+
preload: true
|
|
109
|
+
)
|
|
63
110
|
# The overridable confirm resolver (issue #55). reactive_controller.js
|
|
64
111
|
# imports it by this BARE specifier — `import { confirmResolver } from
|
|
65
112
|
# "phlex/reactive/confirm"` — NOT a relative "./confirm.js" (issue #57:
|
|
@@ -102,6 +149,19 @@ module Phlex
|
|
|
102
149
|
to: "phlex/reactive/inspect.min.js",
|
|
103
150
|
preload: false
|
|
104
151
|
)
|
|
152
|
+
# Feature modules (issue #275), for the opt-in phlex/reactive/core.
|
|
153
|
+
# NOT preloaded: the core imports one only when a root on the page
|
|
154
|
+
# uses it, so a page without such a root never fetches it — and the
|
|
155
|
+
# default client never does. An app on the core that knows it needs
|
|
156
|
+
# one on every page re-pins it with preload: true.
|
|
157
|
+
importmap = it.importmap
|
|
158
|
+
CLIENT_FEATURES.each do
|
|
159
|
+
importmap.pin(
|
|
160
|
+
"phlex/reactive/features/#{it}",
|
|
161
|
+
to: "phlex/reactive/features/#{it}.min.js",
|
|
162
|
+
preload: false
|
|
163
|
+
)
|
|
164
|
+
end
|
|
105
165
|
end
|
|
106
166
|
end
|
|
107
167
|
|