phlex-reactive 0.13.3 → 0.14.0

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1097 -630
  3. data/README.md +638 -16
  4. data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
  6. data/app/javascript/phlex/reactive/core.js +181 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -0
  8. data/app/javascript/phlex/reactive/core.min.js.map +11 -0
  9. data/app/javascript/phlex/reactive/early.js +175 -0
  10. data/app/javascript/phlex/reactive/early.min.js +4 -0
  11. data/app/javascript/phlex/reactive/early.min.js.map +10 -0
  12. data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
  13. data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/features/compute.js +541 -0
  16. data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
  17. data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
  18. data/app/javascript/phlex/reactive/features/defer.js +726 -0
  19. data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
  20. data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
  21. data/app/javascript/phlex/reactive/features/devtools.js +242 -0
  22. data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
  23. data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
  24. data/app/javascript/phlex/reactive/features/effects.js +362 -0
  25. data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
  26. data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
  27. data/app/javascript/phlex/reactive/features/form.js +216 -0
  28. data/app/javascript/phlex/reactive/features/form.min.js +4 -0
  29. data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
  30. data/app/javascript/phlex/reactive/features/hints.js +193 -0
  31. data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
  32. data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
  33. data/app/javascript/phlex/reactive/features/persist.js +663 -0
  34. data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
  35. data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
  36. data/app/javascript/phlex/reactive/inspect.js +21 -9
  37. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  38. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  39. data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
  40. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  41. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  42. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  43. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  44. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  45. data/lib/phlex/reactive/component/dsl.rb +74 -2
  46. data/lib/phlex/reactive/component/helpers.rb +104 -28
  47. data/lib/phlex/reactive/component/lazy.rb +138 -2
  48. data/lib/phlex/reactive/component/registry.rb +2 -0
  49. data/lib/phlex/reactive/component/selection.rb +71 -0
  50. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  51. data/lib/phlex/reactive/doctor.rb +166 -8
  52. data/lib/phlex/reactive/dormant.rb +66 -0
  53. data/lib/phlex/reactive/engine.rb +64 -3
  54. data/lib/phlex/reactive/fragment.rb +233 -0
  55. data/lib/phlex/reactive/inspector/report.rb +24 -3
  56. data/lib/phlex/reactive/inspector.rb +23 -2
  57. data/lib/phlex/reactive/js.rb +61 -12
  58. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  59. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  60. data/lib/phlex/reactive/param_schema.rb +68 -54
  61. data/lib/phlex/reactive/pending/markup.rb +128 -0
  62. data/lib/phlex/reactive/pending.rb +25 -21
  63. data/lib/phlex/reactive/show_conditions.rb +84 -56
  64. data/lib/phlex/reactive/streamable.rb +20 -2
  65. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  66. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  67. data/lib/phlex/reactive/version.rb +1 -1
  68. data/lib/phlex/reactive.rb +84 -0
  69. data/lib/tasks/phlex_reactive.rake +6 -2
  70. metadata +39 -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 = %w[
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).include?("csrf_meta_tags")
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). Apps can change the paths with Phlex::Reactive.action_path /
17
- # .defer_path before boot.
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 (108 KB -> 22 KB for the controller; `rake build:js`), not
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
 
@@ -112,6 +172,7 @@ module Phlex
112
172
  config.to_prepare do
113
173
  Phlex::Reactive::Streamable.reset_all_view_contexts!
114
174
  Phlex::Reactive.reset_stream_builder!
175
+ Phlex::Reactive::Pending::Markup.reset!
115
176
  end
116
177
 
117
178
  # Boot-time guard (issue #26): warn if the action path doesn't resolve to