react-manifest-rails 0.2.22 → 0.2.23

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c87f68d7ed8248c19732ce3b946a1207ed85907a92bd39ab5a2eda665f5b001e
4
- data.tar.gz: 7ee42c73f25048a31d8ca713d0cdfc23b344eb99ec68d1f72c58b5cfda3b635d
3
+ metadata.gz: 907e5e7bb8b3e6a8554009c4e8a87a39379d8bd09c4455fd9c02b082a014c18b
4
+ data.tar.gz: 11d4e6ca8833973107937eee1b82881f620f3b2e74442ab0883521ea5803d58b
5
5
  SHA512:
6
- metadata.gz: d71e1c8b255678a3b1a72df766134b80dadd8bf7553d4380e240d3a8b526dff5e6f6ef848574b93c86f6778feef896cc04b945a0f020d03d0bccf034acfe7d74
7
- data.tar.gz: 874538d0cd06e8b8a121d9343a3846649b6612cd3ad4bb8c0c28a7c1589c1e05bf2f48b80ae0fe6a8af478d07a3d4309d9a5bc692536f4cdebeeed90b89ca74c
6
+ metadata.gz: 950abc3b34f1ffe0e8eb6fcd4ae7798469b60746531d0bb0171de0975881cb2572f35182e07785255fcf932ce14ccaac369a1238e2d5e2fe3b8689d323861d06
7
+ data.tar.gz: dc6fcbfb087a8aa9f95ff109147ee0345b2aa32fc1a0281118ffb60d12313735d659ecb55200832eb940bf6def8675ab711e64208fcc0ec17514e71e0ea57f42
data/CHANGELOG.md CHANGED
@@ -7,10 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.23] - 2026-04-21
11
+
10
12
  ### Fixed
11
13
  - `react_component` now emits only `ux_shared` plus the direct owning bundle for the requested component symbol, instead of also emitting transitive controller manifests as separate script tags. This prevents unnecessary network requests for additional `ux_*.js` manifests while preserving dependency loading through generated manifest `require` directives.
12
14
  - Generator now skips `external_roots` and `external_providers` entries that resolve to files already included in `ux_shared`, preventing duplicate runtime declarations from overlapping shared/external includes.
13
15
  - View helper bundle deduplication now canonicalizes bundle names (e.g. `ux_shared` vs `ux_manifests/ux_shared`) to avoid re-emitting equivalent script tags.
16
+ - Controller manifests now include scanner-detected shared dependencies after `ux_shared` removal, including transitive shared dependencies needed by shared components (for example `DataTable` -> `SortHeader`).
17
+ - Shared `ux/lib` utility files are now included in controller manifests, restoring runtime availability for global helper functions such as `formatDate` and `formatCurrency`.
14
18
 
15
19
  ## [0.2.10] - 2026-04-16
16
20
 
@@ -42,11 +42,11 @@ module ReactManifest
42
42
  # written and others stale/missing.
43
43
  def run!
44
44
  classification = @classifier.classify
45
- controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
45
+ scan_result = Scanner.new(@config).scan(classification)
46
+ controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs, scan_result)
46
47
 
47
48
  # Phase 1: build all content in memory — no I/O.
48
49
  manifests = []
49
- manifests << build_shared(classification.shared_dirs)
50
50
  classification.controller_dirs.each { |ctrl| manifests << build_controller(ctrl, controller_context) }
51
51
 
52
52
  migrate_legacy_manifests!
@@ -62,33 +62,20 @@ module ReactManifest
62
62
 
63
63
  # ------------------------------------------------------------------ shared
64
64
 
65
- def build_shared(shared_dirs)
66
- lines = header_lines
67
- any_files = false
68
65
 
69
- shared_dirs.each do |shared_dir|
70
- files = js_files_in(shared_dir[:path])
71
- next if files.empty?
72
-
73
- any_files = true
74
- files.each { |f| lines << "//= require #{relative_require_path(f)}" }
75
- end
76
-
77
- lines << "// (no shared files found)" unless any_files
78
-
79
- { filename: "#{@config.shared_bundle}.js", content: "#{lines.join("\n")}\n" }
80
- end
81
66
 
82
67
  # --------------------------------------------------------------- controller
83
68
 
84
69
  def build_controller(ctrl, controller_context)
85
70
  lines = header_lines
86
71
  dep_requires = controller_dependency_requires(ctrl[:bundle_name], controller_context)
72
+ lib_reqs = controller_context[:shared_lib_requires]
73
+ shared_reqs = controller_context[:shared_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
87
74
  ext_reqs = controller_context[:external_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
88
75
 
89
76
  files = js_files_in(ctrl[:path])
90
77
  own_requires = files.map { |f| relative_require_path(f) }
91
- all_requires = (dep_requires + ext_reqs + own_requires).uniq
78
+ all_requires = (dep_requires + lib_reqs + shared_reqs + ext_reqs + own_requires).uniq
92
79
 
93
80
  if all_requires.empty?
94
81
  lines << "// (no JSX files found in #{ctrl[:name]}/)"
@@ -99,13 +86,24 @@ module ReactManifest
99
86
  { filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
100
87
  end
101
88
 
102
- def build_controller_context(controller_dirs, shared_dirs)
89
+ def build_controller_context(controller_dirs, shared_dirs, scan_result)
103
90
  bundle_files = {}
104
91
  symbol_to_bundle = {}
105
92
  external_symbol_to_require = {}
106
93
  dependencies = Hash.new { |h, k| h[k] = Set.new }
107
94
  external_requires = Hash.new { |h, k| h[k] = Set.new }
108
95
  shared_require_paths = shared_require_path_set(shared_dirs)
96
+ shared_requires = Hash.new { |h, k| h[k] = Set.new }
97
+ shared_dependency_map = build_shared_dependency_map(shared_dirs, shared_require_paths, scan_result)
98
+ shared_lib_requires = shared_lib_require_paths(shared_dirs)
99
+
100
+ controller_dirs.each do |ctrl|
101
+ scan_result.controller_usages.fetch(ctrl[:name], []).each do |req_path|
102
+ shared_requires[ctrl[:bundle_name]] << req_path
103
+ end
104
+ shared_requires[ctrl[:bundle_name]] = expand_shared_requires(shared_requires[ctrl[:bundle_name]],
105
+ shared_dependency_map)
106
+ end
109
107
 
110
108
  # Index controller-defined symbols for cross-app detection
111
109
  controller_dirs.each do |ctrl|
@@ -127,10 +125,6 @@ module ReactManifest
127
125
  abs_root = abs_external_root(root_path)
128
126
  external_js_files_in(abs_root).each do |file_path|
129
127
  req_path = relative_require_path(file_path)
130
- if shared_require_paths.include?(normalize_require_path(req_path))
131
- warn "[ReactManifest] Skipping external_roots file already provided by shared bundle: #{req_path}"
132
- next
133
- end
134
128
 
135
129
  warn_on_external_controller_references(file_path, symbol_to_bundle)
136
130
 
@@ -142,12 +136,6 @@ module ReactManifest
142
136
 
143
137
  # Explicit external_providers win over scanned roots on symbol conflicts
144
138
  @config.external_providers.each do |sym, req_path|
145
- if shared_require_paths.include?(normalize_require_path(req_path))
146
- warn "[ReactManifest] Skipping external provider '#{sym}' because it is already " \
147
- "provided by shared bundle: #{req_path}"
148
- next
149
- end
150
-
151
139
  external_symbol_to_require[sym] = req_path
152
140
  end
153
141
 
@@ -167,6 +155,8 @@ module ReactManifest
167
155
  {
168
156
  bundle_files: bundle_files,
169
157
  dependencies: dependencies,
158
+ shared_lib_requires: shared_lib_requires,
159
+ shared_requires: shared_requires,
170
160
  external_requires: external_requires
171
161
  }
172
162
  end
@@ -370,6 +360,58 @@ module ReactManifest
370
360
  end
371
361
  end
372
362
 
363
+ def shared_lib_require_paths(shared_dirs)
364
+ shared_dirs.each_with_object([]) do |shared_dir, paths|
365
+ next unless File.basename(shared_dir[:path]) == "lib"
366
+
367
+ js_files_in(shared_dir[:path]).each do |file_path|
368
+ paths << normalize_require_path(relative_require_path(file_path))
369
+ end
370
+ end.sort.uniq
371
+ end
372
+
373
+ def build_shared_dependency_map(shared_dirs, shared_require_paths, scan_result)
374
+ dependency_map = Hash.new { |h, k| h[k] = Set.new }
375
+
376
+ shared_symbol_index = scan_result.symbol_index.each_with_object({}) do |(sym, req_path), index|
377
+ normalized = normalize_require_path(req_path)
378
+ next unless shared_require_paths.include?(normalized)
379
+
380
+ index[sym] = normalized
381
+ end
382
+
383
+ shared_dirs.each do |shared_dir|
384
+ js_files_in(shared_dir[:path]).each do |file_path|
385
+ from_req = normalize_require_path(relative_require_path(file_path))
386
+ extract_used_component_symbols(file_path).each do |sym|
387
+ to_req = shared_symbol_index[sym]
388
+ next if to_req.nil? || to_req == from_req
389
+
390
+ dependency_map[from_req] << to_req
391
+ end
392
+ end
393
+ end
394
+
395
+ dependency_map
396
+ end
397
+
398
+ def expand_shared_requires(initial_requires, dependency_map)
399
+ expanded = Set.new(initial_requires)
400
+ queue = initial_requires.to_a
401
+
402
+ until queue.empty?
403
+ req = queue.shift
404
+ dependency_map.fetch(req, Set.new).each do |dep_req|
405
+ next if expanded.include?(dep_req)
406
+
407
+ expanded << dep_req
408
+ queue << dep_req
409
+ end
410
+ end
411
+
412
+ expanded
413
+ end
414
+
373
415
  def normalize_require_path(path)
374
416
  path.to_s.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
375
417
  end
@@ -9,7 +9,6 @@ module ReactManifest
9
9
  # Phase 1 — builds a symbol index from shared dirs:
10
10
  # "PrimaryButton" => "ux/components/buttons/primary_button"
11
11
  # "useFetch" => "ux/hooks/use_fetch"
12
- # "formatDate" => "ux/lib/format_date"
13
12
  #
14
13
  # Phase 2 — scans controller files for usage of those symbols
15
14
  # and produces per-controller lists of referenced shared files.
@@ -21,22 +20,19 @@ module ReactManifest
21
20
  DEFINITION_PATTERNS = [
22
21
  # CommonJS / variable-assignment style
23
22
  /(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/, # const FooBar =
24
- /function\s+([A-Z][A-Za-z0-9_]*)\s*\(/, # function FooBar(
25
- /class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/, # class FooBar
23
+ /function\s+([A-Z][A-Za-z0-9_]*)\s*\(/, # function FooBar(
24
+ /class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/, # class FooBar
26
25
  /(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/, # const useFoo = (hooks)
27
- /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # function useFoo(
28
- /(?:const|let|var)\s+([a-z][A-Za-z0-9_]{2,})\s*=\s*(?:function|\()/, # const formatDate = function/arrow
29
- /^function\s+([a-z][A-Za-z0-9_]{2,})\s*\(/, # function formatDate( at line start
26
+ /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # function useFoo(
30
27
 
31
28
  # ES module style (export default / named exports)
32
- /^export\s+default\s+(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/, # export default function Foo
33
- /^export\s+default\s+(?:function|class)\s+(use[A-Z][A-Za-z0-9_]*)/, # export default function useFoo
34
- /^export\s+(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/, # export const Foo =
35
- /^export\s+(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/, # export const useFoo =
36
- /^export\s+(?:const|let|var)\s+([a-z][A-Za-z0-9_]{2,})\s*=\s*(?:function|\()/, # export const formatDate =
37
- /^export\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(/, # export function Foo(
38
- /^export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # export function useFoo(
39
- /^export\s+class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/ # export class Foo
29
+ /^export\s+default\s+(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/,
30
+ /^export\s+default\s+(?:function|class)\s+(use[A-Z][A-Za-z0-9_]*)/,
31
+ /^export\s+(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
32
+ /^export\s+(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
33
+ /^export\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
34
+ /^export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
35
+ /^export\s+class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/
40
36
  ].freeze
41
37
 
42
38
  # Patterns to detect usage in controller files.
@@ -66,7 +62,7 @@ module ReactManifest
66
62
 
67
63
  # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
68
64
  def scan(classification)
69
- warnings = []
65
+ warnings = Set.new
70
66
  symbol_index = {}
71
67
  external_file_paths = {} # file_path => relative_require_path for external_roots files
72
68
 
@@ -79,7 +75,7 @@ module ReactManifest
79
75
  symbols = extract_definitions(file_path)
80
76
  symbols.each do |sym|
81
77
  if symbol_index.key?(sym)
82
- warnings << "Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})"
78
+ warnings.add("Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})")
83
79
  else
84
80
  symbol_index[sym] = relative
85
81
  end
@@ -131,7 +127,7 @@ module ReactManifest
131
127
  files = js_files_in(ctrl[:path])
132
128
  used = Set.new
133
129
 
134
- warnings << "Controller dir '#{ctrl[:name]}' has no JS/JSX files" if files.empty? && @config.verbose?
130
+ warnings.add("Controller dir '#{ctrl[:name]}' has no JS/JSX files") if files.empty? && @config.verbose?
135
131
 
136
132
  files.each do |file_path|
137
133
  validate_naming(file_path, ctrl[:name], warnings)
@@ -150,7 +146,7 @@ module ReactManifest
150
146
  Result.new(
151
147
  symbol_index: symbol_index,
152
148
  controller_usages: controller_usages,
153
- warnings: warnings,
149
+ warnings: warnings.to_a,
154
150
  shared_violations: shared_violations,
155
151
  external_violations: external_violations
156
152
  )
@@ -200,8 +196,8 @@ module ReactManifest
200
196
  # Expected: <controller>_index, <controller>_show, <controller>_form, etc.
201
197
  return if basename.start_with?("#{ctrl_name}_") || basename == ctrl_name
202
198
 
203
- warnings << "File '#{File.basename(file_path)}' in '#{ctrl_name}' does not follow " \
204
- "'#{ctrl_name}_<action>.js.jsx' naming convention"
199
+ warnings.add("File '#{File.basename(file_path)}' in '#{ctrl_name}' does not follow " \
200
+ "'#{ctrl_name}_<action>.js.jsx' naming convention")
205
201
  end
206
202
 
207
203
  def detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
@@ -225,9 +221,9 @@ module ReactManifest
225
221
  info = controller_symbol_index[sym]
226
222
  violations << { shared_file: relative, symbol: sym,
227
223
  controller: info[:controller], app_file: info[:file] }
228
- warnings << "Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
224
+ warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
229
225
  "(from ux/app/#{info[:controller]}). " \
230
- "Move '#{sym}' to a shared dir or the shared file will be incomplete."
226
+ "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
231
227
  end
232
228
  end
233
229
  end
@@ -255,9 +251,9 @@ module ReactManifest
255
251
  info = controller_symbol_index[sym]
256
252
  violations << { external_file: relative, symbol: sym,
257
253
  controller: info[:controller], app_file: info[:file] }
258
- warnings << "External file '#{relative}' uses app-dir symbol '#{sym}' " \
254
+ warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
259
255
  "(from ux/app/#{info[:controller]}). " \
260
- "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations."
256
+ "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
261
257
  end
262
258
  end
263
259
  end
@@ -273,8 +269,7 @@ module ReactManifest
273
269
 
274
270
  fanout.each do |file, count|
275
271
  if count > 3
276
- warnings << "High fan-out: '#{file}' is used by #{count} controllers " \
277
- "(consider ensuring it's in the shared bundle)"
272
+ warnings.add("High fan-out: '#{file}' is used by #{count} controllers")
278
273
  end
279
274
  end
280
275
  end
@@ -282,10 +277,10 @@ module ReactManifest
282
277
  def read_controller_file(file_path, warnings)
283
278
  File.read(file_path, encoding: "utf-8")
284
279
  rescue Errno::ENOENT, Errno::EACCES => e
285
- warnings << "Skipping #{file_path}: #{e.message}"
280
+ warnings.add("Skipping #{file_path}: #{e.message}")
286
281
  nil
287
282
  rescue Encoding::InvalidByteSequenceError
288
- warnings << "Skipping #{file_path}: not valid UTF-8"
283
+ warnings.add("Skipping #{file_path}: not valid UTF-8")
289
284
  nil
290
285
  end
291
286
 
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.22".freeze
2
+ VERSION = "0.2.23".freeze
3
3
  end
@@ -5,11 +5,10 @@ module ReactManifest
5
5
  # <%= react_bundle_tag %>
6
6
  #
7
7
  # Resolves which ux_*.js bundles to include based on controller_path:
8
- # 1. Always includes config.shared_bundle (e.g. "ux_shared")
9
- # 2. Always appends config.always_include (e.g. ["ux_main"])
10
- # 3. Appends "ux_<controller_path>" if that bundle file exists
11
- # 4. For namespaced controllers (admin/users): checks ux_admin_users, then ux_admin
12
- # 5. Returns "" for pure ERB/HAML pages with no matching bundle, or when
8
+ # 1. Appends config.always_include (e.g. ["ux_main"])
9
+ # 2. Appends "ux_<controller_path>" if that bundle file exists
10
+ # 3. For namespaced controllers (admin/users): checks ux_admin_users, then ux_admin
11
+ # 4. Returns "" for pure ERB/HAML pages with no matching bundle, or when
13
12
  # called outside a controller context (mailers, engines, etc.)
14
13
  module ViewHelpers
15
14
  def react_bundle_tag(**html_options)
@@ -22,10 +21,14 @@ module ReactManifest
22
21
  return "".html_safe if bundles.empty?
23
22
 
24
23
  # Record emitted bundles so react_component doesn't re-emit them.
25
- emitted = (@_react_manifest_emitted_bundles ||= [])
26
- bundles.each { |b| emitted << b unless emitted_bundle?(emitted, b) }
24
+ emitted = emitted_bundles
25
+ fresh_bundles = bundles.reject { |b| emitted_bundle?(emitted, b) }
26
+ return "".html_safe if fresh_bundles.empty?
27
27
 
28
- asset_names = bundles.map { |bundle| "#{bundle}.js" }
28
+ fresh_bundles.each { |b| emitted << b }
29
+ mark_bundle_tag_rendered
30
+
31
+ asset_names = fresh_bundles.map { |bundle| "#{bundle}.js" }
29
32
  javascript_include_tag(*asset_names, extname: false, **html_options)
30
33
  end
31
34
 
@@ -35,12 +38,13 @@ module ReactManifest
35
38
  # This avoids strict dependence on controller_path -> bundle naming alignment.
36
39
  def react_component(*args, **kwargs, &block)
37
40
  html = super
41
+ return html if bundle_tag_rendered?
38
42
 
39
43
  component_name = args.first
40
44
  bundles = ReactManifest.resolve_bundles_for_component_direct(component_name)
41
45
  return html if bundles.empty?
42
46
 
43
- emitted = (@_react_manifest_emitted_bundles ||= [])
47
+ emitted = emitted_bundles
44
48
 
45
49
  new_tags = bundles.filter_map do |bundle|
46
50
  next if emitted_bundle?(emitted, bundle)
@@ -64,5 +68,27 @@ module ReactManifest
64
68
  def canonical_bundle_name(bundle)
65
69
  bundle.to_s.split("/").last
66
70
  end
71
+
72
+ def emitted_bundles
73
+ # ActionView can instantiate multiple helper contexts during one request.
74
+ # Store emitted bundles in request env so layout + template helpers dedupe.
75
+ if respond_to?(:request, true) && request
76
+ request.env["react_manifest.emitted_bundles"] ||= []
77
+ else
78
+ @_react_manifest_emitted_bundles ||= []
79
+ end
80
+ end
81
+
82
+ def mark_bundle_tag_rendered
83
+ return unless respond_to?(:request, true) && request
84
+
85
+ request.env["react_manifest.bundle_tag_rendered"] = true
86
+ end
87
+
88
+ def bundle_tag_rendered?
89
+ return false unless respond_to?(:request, true) && request
90
+
91
+ request.env["react_manifest.bundle_tag_rendered"] == true
92
+ end
67
93
  end
68
94
  end
@@ -130,12 +130,6 @@ namespace :react_manifest do
130
130
  scan_result = scanner.scan(classification)
131
131
  dep_map = ReactManifest::DependencyMap.new(scan_result)
132
132
  dep_map.print_report
133
-
134
- unless scan_result.warnings.empty?
135
- puts "Warnings (#{scan_result.warnings.size}):"
136
- scan_result.warnings.each { |w| puts " ⚠ #{w}" }
137
- puts
138
- end
139
133
  end
140
134
 
141
135
  desc "Analyze application*.js files — show what migrate_application would change"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react-manifest-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.22
4
+ version: 0.2.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan