react-manifest-rails 0.2.21 → 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: a68f022a75a440c68e741e931c5453e6c96c126380356757825a984de94e2143
4
- data.tar.gz: 52d20e65e685a1b82d833ee650677c91a6fc5faa9e7b32c1075d85a2e6f53fa7
3
+ metadata.gz: 907e5e7bb8b3e6a8554009c4e8a87a39379d8bd09c4455fd9c02b082a014c18b
4
+ data.tar.gz: 11d4e6ca8833973107937eee1b82881f620f3b2e74442ab0883521ea5803d58b
5
5
  SHA512:
6
- metadata.gz: bfd3abb4dde7e8d661df2623b42345a6f5a191f6b119ce25d8913ab094261ea76592ea435709a9fd80d320703673deb073b4df2a8de90a2fb76d6cf43532272b
7
- data.tar.gz: cef2fd10f820ebf0a1583772fa7236e34f5c5d2744e076f682111d374cf220c265b2625d02a6f88d0274baaaa8e215eb55cfec353e528d85ac82327ef466dc6f
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
 
data/README.md CHANGED
@@ -235,6 +235,18 @@ If you see errors like `UserSignInForm is not defined` (often from `eval` inside
235
235
 
236
236
  Using `defer: true` can cause `react_component` inline scripts to run before your `ux_*.js` bundles are executed.
237
237
 
238
+ ### `Identifier '<Name>' has already been declared`
239
+
240
+ This usually means the same component/function is being loaded from two separate bundles.
241
+
242
+ Common cause: a file outside `ux/` (for example `app/assets/javascripts/components/navbar/*` loaded by `application.js`) references a symbol defined in `ux/app/<controller>/...`, while that controller bundle is also loaded via `react_bundle_tag`.
243
+
244
+ Recommended fix:
245
+ - Move globally reused symbols out of `ux/app/*` into a shared dir (`ux/components`, `ux/hooks`, `ux/lib`) so they are emitted once via `ux_shared.js`.
246
+ - Keep controller-specific symbols in `ux/app/*` and avoid importing/using them from non-ux global assets.
247
+
248
+ If you use `external_roots`, `react_manifest:analyze` and generation warnings will now flag this pattern explicitly.
249
+
238
250
  ## Compatibility
239
251
 
240
252
  - Ruby: 3.2+
@@ -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,8 @@ 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
128
+
129
+ warn_on_external_controller_references(file_path, symbol_to_bundle)
134
130
 
135
131
  extract_defined_symbols(file_path).each do |sym|
136
132
  external_symbol_to_require[sym] ||= req_path
@@ -140,12 +136,6 @@ module ReactManifest
140
136
 
141
137
  # Explicit external_providers win over scanned roots on symbol conflicts
142
138
  @config.external_providers.each do |sym, req_path|
143
- if shared_require_paths.include?(normalize_require_path(req_path))
144
- warn "[ReactManifest] Skipping external provider '#{sym}' because it is already " \
145
- "provided by shared bundle: #{req_path}"
146
- next
147
- end
148
-
149
139
  external_symbol_to_require[sym] = req_path
150
140
  end
151
141
 
@@ -165,6 +155,8 @@ module ReactManifest
165
155
  {
166
156
  bundle_files: bundle_files,
167
157
  dependencies: dependencies,
158
+ shared_lib_requires: shared_lib_requires,
159
+ shared_requires: shared_requires,
168
160
  external_requires: external_requires
169
161
  }
170
162
  end
@@ -368,10 +360,73 @@ module ReactManifest
368
360
  end
369
361
  end
370
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
+
371
415
  def normalize_require_path(path)
372
416
  path.to_s.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
373
417
  end
374
418
 
419
+ def warn_on_external_controller_references(file_path, symbol_to_bundle)
420
+ extract_used_component_symbols(file_path).each do |sym|
421
+ dep_bundle = symbol_to_bundle[sym]
422
+ next unless dep_bundle
423
+
424
+ warn "[ReactManifest] External file '#{relative_require_path(file_path)}' references " \
425
+ "controller-only symbol '#{sym}' (#{dep_bundle}). " \
426
+ "Move '#{sym}' to a shared ux dir to avoid duplicate runtime declarations."
427
+ end
428
+ end
429
+
375
430
  def auto_generated?(path)
376
431
  # Avoid TOCTOU: don't check existence separately — just attempt the read
377
432
  # and treat a missing/unreadable file as not auto-generated.
@@ -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.
@@ -57,7 +53,8 @@ module ReactManifest
57
53
  Object Array String Number Boolean Symbol Map Set WeakMap
58
54
  ].freeze
59
55
 
60
- Result = Struct.new(:symbol_index, :controller_usages, :warnings, :shared_violations, keyword_init: true)
56
+ Result = Struct.new(:symbol_index, :controller_usages, :warnings, :shared_violations,
57
+ :external_violations, keyword_init: true)
61
58
 
62
59
  def initialize(config = ReactManifest.configuration)
63
60
  @config = config
@@ -65,8 +62,9 @@ module ReactManifest
65
62
 
66
63
  # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
67
64
  def scan(classification)
68
- warnings = []
65
+ warnings = Set.new
69
66
  symbol_index = {}
67
+ external_file_paths = {} # file_path => relative_require_path for external_roots files
70
68
 
71
69
  # Phase 1a: index symbols from shared dirs
72
70
  shared_file_paths = {} # file_path => relative_require_path for all shared files
@@ -77,7 +75,7 @@ module ReactManifest
77
75
  symbols = extract_definitions(file_path)
78
76
  symbols.each do |sym|
79
77
  if symbol_index.key?(sym)
80
- warnings << "Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})"
78
+ warnings.add("Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})")
81
79
  else
82
80
  symbol_index[sym] = relative
83
81
  end
@@ -90,7 +88,8 @@ module ReactManifest
90
88
  abs_root = abs_external_root(root_path)
91
89
  js_files_in(abs_root).each do |file_path|
92
90
  relative = relative_require_path(file_path)
93
- symbols = extract_definitions(file_path)
91
+ external_file_paths[file_path] = relative
92
+ symbols = extract_definitions(file_path)
94
93
  symbols.each do |sym|
95
94
  symbol_index[sym] ||= relative
96
95
  end
@@ -119,6 +118,7 @@ module ReactManifest
119
118
 
120
119
  # Phase 1e: detect shared files that use app-dir (controller) symbols
121
120
  shared_violations = detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
121
+ external_violations = detect_external_root_violations(external_file_paths, controller_symbol_index, warnings)
122
122
 
123
123
  # Phase 2: scan controller dirs for usage
124
124
  controller_usages = {}
@@ -127,7 +127,7 @@ module ReactManifest
127
127
  files = js_files_in(ctrl[:path])
128
128
  used = Set.new
129
129
 
130
- 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?
131
131
 
132
132
  files.each do |file_path|
133
133
  validate_naming(file_path, ctrl[:name], warnings)
@@ -146,8 +146,9 @@ module ReactManifest
146
146
  Result.new(
147
147
  symbol_index: symbol_index,
148
148
  controller_usages: controller_usages,
149
- warnings: warnings,
150
- shared_violations: shared_violations
149
+ warnings: warnings.to_a,
150
+ shared_violations: shared_violations,
151
+ external_violations: external_violations
151
152
  )
152
153
  end
153
154
  # rubocop:enable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
@@ -195,8 +196,8 @@ module ReactManifest
195
196
  # Expected: <controller>_index, <controller>_show, <controller>_form, etc.
196
197
  return if basename.start_with?("#{ctrl_name}_") || basename == ctrl_name
197
198
 
198
- warnings << "File '#{File.basename(file_path)}' in '#{ctrl_name}' does not follow " \
199
- "'#{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")
200
201
  end
201
202
 
202
203
  def detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
@@ -220,9 +221,39 @@ module ReactManifest
220
221
  info = controller_symbol_index[sym]
221
222
  violations << { shared_file: relative, symbol: sym,
222
223
  controller: info[:controller], app_file: info[:file] }
223
- warnings << "Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
224
+ warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
225
+ "(from ux/app/#{info[:controller]}). " \
226
+ "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
227
+ end
228
+ end
229
+ end
230
+ violations
231
+ end
232
+
233
+ def detect_external_root_violations(external_file_paths, controller_symbol_index, warnings)
234
+ violations = []
235
+ external_file_paths.each do |file_path, relative|
236
+ content = begin
237
+ File.read(file_path, encoding: "utf-8")
238
+ rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
239
+ next
240
+ end
241
+
242
+ local_syms = Set.new
243
+ DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
244
+
245
+ [PASCAL_TOKEN_PATTERN, HOOK_TOKEN_PATTERN].each do |pattern|
246
+ content.scan(pattern) do |match|
247
+ sym = match[0]
248
+ next if local_syms.include?(sym)
249
+ next unless controller_symbol_index.key?(sym)
250
+
251
+ info = controller_symbol_index[sym]
252
+ violations << { external_file: relative, symbol: sym,
253
+ controller: info[:controller], app_file: info[:file] }
254
+ warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
224
255
  "(from ux/app/#{info[:controller]}). " \
225
- "Move '#{sym}' to a shared dir or the shared file will be incomplete."
256
+ "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
226
257
  end
227
258
  end
228
259
  end
@@ -238,8 +269,7 @@ module ReactManifest
238
269
 
239
270
  fanout.each do |file, count|
240
271
  if count > 3
241
- warnings << "High fan-out: '#{file}' is used by #{count} controllers " \
242
- "(consider ensuring it's in the shared bundle)"
272
+ warnings.add("High fan-out: '#{file}' is used by #{count} controllers")
243
273
  end
244
274
  end
245
275
  end
@@ -247,10 +277,10 @@ module ReactManifest
247
277
  def read_controller_file(file_path, warnings)
248
278
  File.read(file_path, encoding: "utf-8")
249
279
  rescue Errno::ENOENT, Errno::EACCES => e
250
- warnings << "Skipping #{file_path}: #{e.message}"
280
+ warnings.add("Skipping #{file_path}: #{e.message}")
251
281
  nil
252
282
  rescue Encoding::InvalidByteSequenceError
253
- warnings << "Skipping #{file_path}: not valid UTF-8"
283
+ warnings.add("Skipping #{file_path}: not valid UTF-8")
254
284
  nil
255
285
  end
256
286
 
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.21".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.21
4
+ version: 0.2.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan