phlex-reactive 0.13.4 → 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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1050 -630
  3. data/README.md +561 -14
  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/reactive_controller.js +85 -5821
  37. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  38. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
  39. data/app/javascript/phlex/reactive/runtime.js +3690 -0
  40. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
  41. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
  42. data/lib/phlex/reactive/component/dsl.rb +74 -2
  43. data/lib/phlex/reactive/component/helpers.rb +39 -9
  44. data/lib/phlex/reactive/component/lazy.rb +138 -2
  45. data/lib/phlex/reactive/component/registry.rb +2 -0
  46. data/lib/phlex/reactive/component/selection.rb +71 -0
  47. data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
  48. data/lib/phlex/reactive/doctor.rb +166 -8
  49. data/lib/phlex/reactive/dormant.rb +66 -0
  50. data/lib/phlex/reactive/engine.rb +63 -3
  51. data/lib/phlex/reactive/fragment.rb +233 -0
  52. data/lib/phlex/reactive/inspector/report.rb +24 -3
  53. data/lib/phlex/reactive/inspector.rb +23 -2
  54. data/lib/phlex/reactive/js.rb +17 -2
  55. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
  56. data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
  57. data/lib/phlex/reactive/param_schema.rb +68 -54
  58. data/lib/phlex/reactive/show_conditions.rb +84 -56
  59. data/lib/phlex/reactive/streamable.rb +20 -2
  60. data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
  61. data/lib/phlex/reactive/test_helpers/system.rb +62 -0
  62. data/lib/phlex/reactive/version.rb +1 -1
  63. data/lib/phlex/reactive.rb +84 -0
  64. data/lib/tasks/phlex_reactive.rake +6 -2
  65. metadata +38 -1
@@ -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
 
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module Phlex
8
+ module Reactive
9
+ # Privately cacheable lazy renders (issue #277): the signed id and URL of a
10
+ # `reactive_lazy cache: { max_age: }` component's GET fragment.
11
+ #
12
+ # The id is a DETERMINISTIC signature over the component's identity payload
13
+ # — no expiry, no actor binding, purpose-scoped — so the same component
14
+ # renders the same URL on every page view and the browser's private HTTP
15
+ # cache can reuse the response. It names NO viewer: who the render is for
16
+ # always comes from the session at the endpoint. (Like the identity token it
17
+ # does carry the signed state / record gid — signed, not encrypted.) The
18
+ # purpose makes it disjoint
19
+ # from the identity and defer tokens: a fragment id can't run an action or
20
+ # reach the defer endpoint, and neither of those tokens resolves here.
21
+ #
22
+ # Two unsigned query parameters only shape the browser's cache KEY — the
23
+ # render never reads them:
24
+ # v — a digest of the component's reactive_cache_version (content changed)
25
+ # u — a KEYED digest of its reactive_cache_viewer (who the render is
26
+ # for). The endpoint recomputes it in the CURRENT session and makes
27
+ # the reply cacheable only when it matches, so a URL can only ever
28
+ # hold the render of the viewer it names. A blank viewer names nobody:
29
+ # no `u`, and the reply falls back to `Vary: Cookie`.
30
+ module Fragment
31
+ # The purpose of the previous id format (still verified, see verify).
32
+ PURPOSE = "phlex-reactive/fragment"
33
+ # The purpose the current id's MAC is keyed under (issue #306).
34
+ ID_PURPOSE = "phlex-reactive/fragment-id"
35
+ # 128 bits of MAC: 22 base64url characters.
36
+ MAC_BYTES = 16
37
+ MAC_LENGTH = 22
38
+ BASE64URL = /\A[A-Za-z0-9_-]+\z/
39
+ VIEWER_PURPOSE = "phlex-reactive/fragment-viewer"
40
+ # A viewer is an identity — an id, or a few of them. These bound how far
41
+ # a collection is walked, so no value can make the shell render slow.
42
+ VIEWER_PART_LIMIT = 32
43
+ VIEWER_NODE_LIMIT = 64
44
+ # Components already warned about (by class name): once per component.
45
+ VIEWER_WARNED = Concurrent::Map.new
46
+
47
+ # A reactive_cache_viewer value that cannot name a viewer. Internal: it
48
+ # carries the reason from the walk to viewer_param, which logs it.
49
+ class UnusableViewer < StandardError; end
50
+
51
+ module_function
52
+
53
+ # The signed id of an identity payload (issue #306):
54
+ #
55
+ # base64url(JSON payload) + base64url(128-bit MAC)
56
+ #
57
+ # The payload is encoded ONCE and the MAC is a fixed 22 characters, so the
58
+ # id is one URL path segment of base64url characters with no separator
59
+ # (a "." would read as a format suffix to the router). The MAC is keyed
60
+ # through the configured verifier — a digest of its signature over the
61
+ # encoded payload under its own purpose, the same construction as the
62
+ # viewer digest — so it works with any verifier the app configured and
63
+ # cannot be computed without the app's secret. Deterministic: the same
64
+ # identity always renders the same URL, which is what lets the browser's
65
+ # cache hit.
66
+ def sign(payload)
67
+ data = Base64.urlsafe_encode64(JSON.generate(payload.merge("v" => TOKEN_VERSION)), padding: false)
68
+ "#{data}#{compact_mac(data)}"
69
+ end
70
+
71
+ # The verified, version-upgraded payload, or nil — tampered, a token of
72
+ # another purpose, or no fragment id at all. The current format is tried
73
+ # first, then the previous one: a browser may still hold a page or a
74
+ # cached fragment URL minted before #306 (for up to
75
+ # fragment_cache_max_age_limit).
76
+ def verify(id)
77
+ id = id.to_s
78
+ payload = verify_compact(id) || verify_legacy(id)
79
+ payload && Phlex::Reactive.upgrade_token(payload)
80
+ end
81
+
82
+ # The MAC is checked BEFORE the data is decoded or parsed. Anything but
83
+ # base64url characters is not an id (and never reaches the verifier).
84
+ def verify_compact(id)
85
+ return nil unless id.length > MAC_LENGTH && id.match?(BASE64URL)
86
+
87
+ data = id[0...-MAC_LENGTH]
88
+ return nil unless ActiveSupport::SecurityUtils.secure_compare(compact_mac(data), id[-MAC_LENGTH..])
89
+
90
+ payload = JSON.parse(Base64.urlsafe_decode64(data))
91
+ payload.is_a?(::Hash) ? payload : nil
92
+ rescue ::ArgumentError, JSON::ParserError
93
+ nil
94
+ end
95
+
96
+ # The previous format: the verifier's token (Base64 + "--" + digest)
97
+ # wrapped in urlsafe Base64. Kept for one release (issue #306).
98
+ def verify_legacy(id)
99
+ Phlex::Reactive.verifier.verified(Base64.urlsafe_decode64(id), purpose: PURPOSE)
100
+ rescue ::ArgumentError
101
+ nil
102
+ end
103
+
104
+ # `data` is pinned to US-ASCII (it is base64url): the id is minted as a
105
+ # US-ASCII String but arrives as a UTF-8 param, and a Marshal-serializing
106
+ # verifier (load_defaults < 7.1) signs the encoding along with the bytes.
107
+ def compact_mac(data)
108
+ data = data.dup.force_encoding(Encoding::US_ASCII)
109
+ digest_param(Digest::SHA256.digest(Phlex::Reactive.verifier.generate(data, purpose: ID_PURPOSE)), MAC_BYTES)
110
+ end
111
+
112
+ # The fragment URL the shell renders into data-reactive-defer-src:
113
+ # <fragment_path>/<signed id>[?v=<version digest>][&u=<viewer digest>].
114
+ # `version` is the component's reactive_cache_version (nil → no `v`);
115
+ # `viewer` is its ALREADY-DIGESTED viewer_param (nil → no `u`).
116
+ def src(payload, version: nil, viewer: nil)
117
+ query = []
118
+ query << "v=#{version_param(version)}" unless version.nil?
119
+ query << "u=#{viewer}" if viewer
120
+ path = "#{Phlex::Reactive.fragment_path}/#{sign(payload)}"
121
+ query.empty? ? path : "#{path}?#{query.join("&")}"
122
+ end
123
+
124
+ # `v` only varies the browser's cache KEY — the endpoint never reads it —
125
+ # so it is digested: short, opaque, and safe for any value an app returns
126
+ # (a Time keeps its sub-second precision, a record its cache key). 64
127
+ # bits as 11 base64url characters: a cache-buster, not a secret.
128
+ def version_param(version)
129
+ key = version.respond_to?(:utc) ? version.utc.strftime("%Y%m%d%H%M%S%N") : version
130
+ digest_param(Digest::SHA256.digest(ActiveSupport::Cache.expand_cache_key(key)), 8)
131
+ end
132
+
133
+ def digest_param(digest, bytes)
134
+ Base64.urlsafe_encode64(digest.byteslice(0, bytes), padding: false)
135
+ end
136
+
137
+ # The `u` of a reactive_cache_viewer value, or nil when the value names
138
+ # NO viewer — nil, false, a blank String, or an Array with any blank part
139
+ # or Hash with any blank leaf (`[Current.user&.id, locale]` signed out),
140
+ # or a value whose cache key is empty. A blank viewer must never
141
+ # become a shared "anonymous" key: every signed-out or mis-resolved
142
+ # session would then store and read ONE url with no Vary. nil sends the
143
+ # component back to the default mode (`Vary: Cookie`) for that render.
144
+ #
145
+ # KEYED: the digest is taken over the verifier's signature of the value,
146
+ # so it can be neither reversed to the value (a user id is a small space)
147
+ # nor computed for someone else without the app's secret. 128 bits, as
148
+ # 22 base64url characters.
149
+ #
150
+ # NEVER RAISES and never takes long, whatever the hook returned: this runs
151
+ # inside the host page's render. A value that cannot be turned into a key
152
+ # (it raises, it is endless, it is huge) names nobody — the fail-closed
153
+ # default mode — and is logged once per component (`owner`, its class).
154
+ def viewer_param(viewer, owner: nil)
155
+ return nil unless viewer_named?(viewer)
156
+
157
+ signed = Phlex::Reactive.verifier.generate(ActiveSupport::Cache.expand_cache_key(viewer),
158
+ purpose: VIEWER_PURPOSE)
159
+ digest_param(Digest::SHA256.digest(signed), 16)
160
+ rescue UnusableViewer => e
161
+ warn_unusable_viewer(owner, e.message)
162
+ rescue StandardError => e
163
+ warn_unusable_viewer(owner, "#{e.class}: #{e.message}")
164
+ end
165
+
166
+ def viewer_named?(viewer)
167
+ named_within?(viewer, [VIEWER_NODE_LIMIT])
168
+ end
169
+
170
+ # Every LEAF must name something. A collection — anything
171
+ # expand_cache_key walks with to_a: an Array, a Hash (keys and values), a
172
+ # Set, a Struct, a sized Enumerator, nested — with one blank part is
173
+ # unnamed, and so is a value whose cache key expands to nothing (an
174
+ # object with a blank to_param). 0 is a viewer.
175
+ #
176
+ # Bounded: a Range is never walked, a collection of more than
177
+ # VIEWER_PART_LIMIT parts or a walk of more than VIEWER_NODE_LIMIT values
178
+ # (a self-referential Array, a to_a that returns itself) is unnamed.
179
+ # `budget` is a one-element counter shared by the whole walk.
180
+ def named_within?(value, budget)
181
+ return false if value.nil? || value == false
182
+ raise UnusableViewer, "a Range is not an identity" if value.is_a?(::Range)
183
+ raise UnusableViewer, "too deep or self-referential" if (budget[0] -= 1).negative?
184
+ return viewer_leaf?(value) unless value.respond_to?(:to_a) && !value.respond_to?(:cache_key)
185
+
186
+ size = value.respond_to?(:size) ? value.size : nil
187
+ raise UnusableViewer, "a collection of unknown or excessive size" unless viewer_size_ok?(size, value)
188
+
189
+ parts = value.to_a
190
+ raise UnusableViewer, "more than #{VIEWER_PART_LIMIT} parts" if parts.size > VIEWER_PART_LIMIT
191
+
192
+ parts.any? && parts.all? { named_within?(it, budget) }
193
+ end
194
+
195
+ def viewer_leaf?(value)
196
+ value.present? && ActiveSupport::Cache.expand_cache_key(value).present?
197
+ end
198
+
199
+ # An Enumerator must report a finite size; anything sized must be small.
200
+ def viewer_size_ok?(size, value)
201
+ return size <= VIEWER_PART_LIMIT if size.is_a?(::Integer)
202
+
203
+ !value.is_a?(::Enumerator) && size.nil?
204
+ end
205
+
206
+ # Logs why — once per COMPONENT, naming it, since the cause is that
207
+ # component's hook — and answers nil: "this value names nobody".
208
+ def warn_unusable_viewer(owner, reason)
209
+ name = owner.respond_to?(:name) ? owner.name.to_s : owner.to_s
210
+ return nil if VIEWER_WARNED.put_if_absent(name, true)
211
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
212
+
213
+ ::Rails.logger&.warn(
214
+ "[phlex-reactive] #{name.presence || "a component"}#reactive_cache_viewer returned a value that " \
215
+ "cannot name a viewer (#{reason}) — treated as no viewer (Vary: Cookie). Return an id, or a small " \
216
+ "Array of ids."
217
+ )
218
+ nil
219
+ end
220
+
221
+ # Test seam: let the once-per-component warnings fire again.
222
+ def reset_viewer_warning!
223
+ VIEWER_WARNED.clear
224
+ end
225
+
226
+ # The max-age (seconds) the endpoint answers with: the component's
227
+ # declared value, capped by Phlex::Reactive.fragment_cache_max_age_limit.
228
+ def max_age_for(component_class)
229
+ [component_class.reactive_lazy_cache.fetch(:max_age), Phlex::Reactive.fragment_cache_max_age_limit.to_i].min
230
+ end
231
+ end
232
+ end
233
+ end