react-manifest-rails 0.2.34 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,6 +52,31 @@ function definitionsFromAst(ast) {
52
52
  return [...new Set(defs)];
53
53
  }
54
54
 
55
+ // Top-level const/let/class binding names of any casing, including destructured
56
+ // ones. Only these throw "Identifier already declared" when two classic scripts
57
+ // share a global lexical scope; var/function redeclaration is legal, so skipped.
58
+ function lexicalBindingsFromAst(ast) {
59
+ const names = [];
60
+ const collect = (p) => {
61
+ if (!p) return;
62
+ switch (p.type) {
63
+ case "Identifier": names.push(p.name); break;
64
+ case "ObjectPattern": p.properties.forEach((prop) => collect(prop.type === "RestElement" ? prop.argument : prop.value)); break;
65
+ case "ArrayPattern": p.elements.forEach(collect); break;
66
+ case "AssignmentPattern": collect(p.left); break;
67
+ case "RestElement": collect(p.argument); break;
68
+ default: break;
69
+ }
70
+ };
71
+ for (const node of ast.body) {
72
+ const decl = node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" ? node.declaration : node;
73
+ if (!decl) continue;
74
+ if (decl.type === "VariableDeclaration" && decl.kind !== "var") decl.declarations.forEach((d) => collect(d.id));
75
+ else if (decl.type === "ClassDeclaration") collect(decl.id);
76
+ }
77
+ return [...new Set(names)];
78
+ }
79
+
55
80
  // Real reference positions only: JSX element names, call/new callees, and
56
81
  // generic identifier references (prop values, array elements, assignments,
57
82
  // arguments) — explicitly excluding JSXText and StringLiteral content. This
@@ -124,6 +149,13 @@ globalThis.__astExtractor = {
124
149
  return { success: false, error: errorInfo(e) };
125
150
  }
126
151
  },
152
+ extractLexicalBindings(content) {
153
+ try {
154
+ return { success: true, bindings: lexicalBindingsFromAst(parse(content)) };
155
+ } catch (e) {
156
+ return { success: false, error: errorInfo(e) };
157
+ }
158
+ },
127
159
  extractUsages(content) {
128
160
  try {
129
161
  const ast = parse(content);
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.34".freeze
2
+ VERSION = "0.3.1".freeze
3
3
  end
@@ -10,25 +10,36 @@ module ReactManifest
10
10
  # 3. For namespaced controllers (admin/users): checks ux_admin_users, then ux_admin
11
11
  # 4. Returns "" for pure ERB/HAML pages with no matching bundle, or when
12
12
  # called outside a controller context (mailers, engines, etc.)
13
+ #
14
+ # Rails renders the action template before the layout, so react_component
15
+ # tags land in the body first. react_bundle_tag hoists them into its own
16
+ # output (the layout's <head>) so Turbolinks never re-evaluates a body script.
13
17
  module ViewHelpers
18
+ INLINE_BUNDLE_TAG = %r{<script\b[^>]*\sdata-react-manifest="([^"]*)"[^>]*></script>}
19
+
14
20
  def react_bundle_tag(**html_options)
15
21
  # controller_path is not available in all view contexts (mailers, engines,
16
22
  # views rendered outside a request). Fall back gracefully rather than raising.
17
23
  ctrl = respond_to?(:controller_path, true) ? controller_path : nil
18
24
  return "".html_safe if ctrl.nil?
19
25
 
20
- bundles = ReactManifest.resolve_bundles(ctrl)
26
+ emitted = emitted_bundles
27
+ hoisted = hoist_inline_bundle_tags
28
+ emitted.reject! { |e| hoisted.any? { |h| canonical_bundle_name(h) == canonical_bundle_name(e) } }
29
+
30
+ bundles = (ReactManifest.resolve_bundles(ctrl) + hoisted).uniq { |b| canonical_bundle_name(b) }
21
31
  return "".html_safe if bundles.empty?
22
32
 
23
33
  # Record emitted bundles so react_component doesn't re-emit them.
24
- emitted = emitted_bundles
25
34
  fresh_bundles = bundles.reject { |b| emitted_bundle?(emitted, b) }
26
35
  return "".html_safe if fresh_bundles.empty?
27
36
 
28
37
  fresh_bundles.each { |b| emitted << b }
29
38
 
30
- asset_names = fresh_bundles.map { |bundle| "#{bundle}.js" }
31
- javascript_include_tag(*asset_names, extname: false, **html_options)
39
+ tags = fresh_bundles.map do |bundle|
40
+ javascript_include_tag("#{bundle}.js", extname: false, **bundle_tag_options(bundle, html_options))
41
+ end
42
+ safe_join(tags, "\n")
32
43
  end
33
44
 
34
45
  # react-rails integration:
@@ -48,7 +59,7 @@ module ReactManifest
48
59
  next if emitted_bundle?(emitted, bundle)
49
60
 
50
61
  emitted << bundle
51
- javascript_include_tag("#{bundle}.js", extname: false)
62
+ javascript_include_tag("#{bundle}.js", extname: false, data: { react_manifest: bundle })
52
63
  end
53
64
 
54
65
  return html if new_tags.empty?
@@ -58,6 +69,32 @@ module ReactManifest
58
69
 
59
70
  private
60
71
 
72
+ # Turbolinks 5 full-reloads when the tracked head elements differ between pages.
73
+ # ux_shared is identical on every page, so tracking it forces one reload per deploy;
74
+ # tracking controller bundles would reload on every cross-controller visit.
75
+ def bundle_tag_options(bundle, html_options)
76
+ shared = canonical_bundle_name(ReactManifest.configuration.shared_bundle)
77
+ return html_options unless canonical_bundle_name(bundle) == shared
78
+
79
+ html_options.merge(data: (html_options[:data] || {}).merge(turbolinks_track: "reload"))
80
+ end
81
+
82
+ # Strips the inline tags react_component already wrote into the rendered
83
+ # template (and content_for) buffers; returns their bundle names in order.
84
+ def hoist_inline_bundle_tags
85
+ return [] unless respond_to?(:view_flow, true) && view_flow
86
+
87
+ names = []
88
+ view_flow.content.each_key do |key|
89
+ html = view_flow.get(key).to_s
90
+ next unless html.include?("data-react-manifest")
91
+
92
+ names.concat(html.scan(INLINE_BUNDLE_TAG).flatten)
93
+ view_flow.set(key, html.gsub(INLINE_BUNDLE_TAG, "").html_safe)
94
+ end
95
+ names
96
+ end
97
+
61
98
  def emitted_bundle?(emitted, bundle)
62
99
  canonical = canonical_bundle_name(bundle)
63
100
  emitted.any? { |existing| canonical_bundle_name(existing) == canonical }
@@ -7,6 +7,7 @@ require "react_manifest/configuration"
7
7
  require "react_manifest/path_utils"
8
8
  require "react_manifest/tree_classifier"
9
9
  require "react_manifest/symbol_extractor"
10
+ require "react_manifest/component_index"
10
11
  require "react_manifest/scanner"
11
12
  require "react_manifest/dependency_map"
12
13
  require "react_manifest/generator"
@@ -14,11 +15,11 @@ require "react_manifest/application_analyzer"
14
15
  require "react_manifest/application_migrator"
15
16
  require "react_manifest/layout_patcher"
16
17
  require "react_manifest/sprockets_manifest_patcher"
18
+ require "react_manifest/gitignore_patcher"
17
19
  require "react_manifest/watcher"
18
20
  require "react_manifest/reporter"
19
21
  require "react_manifest/view_helpers"
20
22
 
21
- # rubocop:disable Metrics/ModuleLength
22
23
  module ReactManifest
23
24
  class << self
24
25
  def configuration
@@ -55,10 +56,7 @@ module ReactManifest
55
56
  bundles << shared if shared
56
57
 
57
58
  # 2. always_include bundles (e.g. ux_main)
58
- config.always_include.each do |b|
59
- resolved = resolve_bundle_reference(config, b)
60
- bundles << resolved if resolved && !bundles.include?(resolved)
61
- end
59
+ append_always_include(config, bundles)
62
60
 
63
61
  # 3. Controller-specific bundle
64
62
  # Try fully-namespaced first: admin/users → ux_admin_users
@@ -74,6 +72,18 @@ module ReactManifest
74
72
  bundles
75
73
  end
76
74
 
75
+ # Ensure the manifest dir is gitignored (and .keep present). Honors
76
+ # config.manage_gitignore. Returns true if it appended the .gitignore entry.
77
+ # Best-effort: a filesystem error is logged and swallowed (never breaks boot).
78
+ def reconcile_gitignore!(config = configuration)
79
+ return false unless config.manage_gitignore?
80
+
81
+ GitignorePatcher.new(config).reconcile!
82
+ rescue StandardError => e
83
+ Rails.logger&.warn("[ReactManifest] gitignore reconcile skipped: #{e.message}")
84
+ false
85
+ end
86
+
77
87
  # Resolve a controller bundle from a React component symbol.
78
88
  #
79
89
  # This is primarily used to support react-rails `react_component` calls,
@@ -102,6 +112,12 @@ module ReactManifest
102
112
  shared = resolve_bundle_reference(config, config.shared_bundle)
103
113
  bundles << shared if shared
104
114
 
115
+ # always_include bundles are delivered on every page. react_component does
116
+ # NOT go through resolve_bundles, so it must emit them here too — this is
117
+ # what makes always_include symbols available without inlining them into
118
+ # controller manifests (which would double-declare, see Generator).
119
+ append_always_include(config, bundles)
120
+
105
121
  root = resolve_bundle_reference(config, root_bundle)
106
122
  bundles << root if root && !bundles.include?(root)
107
123
 
@@ -142,6 +158,15 @@ module ReactManifest
142
158
 
143
159
  private
144
160
 
161
+ # Append each configured always_include bundle (deduped) to +bundles+.
162
+ def append_always_include(config, bundles)
163
+ config.always_include.each do |b|
164
+ resolved = resolve_bundle_reference(config, b)
165
+ bundles << resolved if resolved && !bundles.include?(resolved)
166
+ end
167
+ bundles
168
+ end
169
+
145
170
  def component_bundle_map(config)
146
171
  component_maps(config)[:symbol_to_bundle]
147
172
  end
@@ -153,79 +178,11 @@ module ReactManifest
153
178
  return cached_maps if cached_key == key
154
179
  end
155
180
 
156
- controller_dirs = TreeClassifier.new(config).classify.controller_dirs
157
- symbol_to_bundle = {}
158
- bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
159
- bundle_files = Hash.new { |h, k| h[k] = [] }
160
- bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
161
-
162
- controller_dirs.each do |ctrl|
163
- bundle_name = ctrl[:bundle_name]
164
- files = js_files_in_controller(ctrl[:path], config)
165
- bundle_files[bundle_name].concat(files)
166
- isolated = config.isolated_app_dirs.include?(ctrl[:name])
167
-
168
- files.each do |file_path|
169
- extract_defined_symbols(file_path).each do |symbol|
170
- next unless symbol.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
171
-
172
- # Keep first writer to ensure deterministic behavior if a symbol is duplicated.
173
- # Isolated dirs never register into the shared symbol index (see
174
- # Generator#build_controller_context for the full rationale).
175
- symbol_to_bundle[symbol] ||= bundle_name unless isolated
176
- bundle_own_symbols[bundle_name] << symbol
177
- end
178
- end
179
- end
180
-
181
- bundle_files.each do |bundle_name, files|
182
- own_symbols = bundle_own_symbols[bundle_name]
183
-
184
- files.each do |file_path|
185
- extract_used_component_symbols(file_path).each do |symbol|
186
- # A symbol the bundle defines itself is always satisfied locally —
187
- # never attribute it to another bundle just because that symbol
188
- # name happens to collide with something defined elsewhere.
189
- next if own_symbols.include?(symbol)
190
-
191
- dep_bundle = symbol_to_bundle[symbol]
192
- next unless dep_bundle && dep_bundle != bundle_name
193
-
194
- bundle_dependencies[bundle_name] << dep_bundle
195
- end
196
- end
197
- end
198
-
199
- maps = {
200
- symbol_to_bundle: symbol_to_bundle,
201
- bundle_dependencies: bundle_dependencies
202
- }
181
+ maps = ComponentIndex.maps(config)
203
182
  @component_maps_cache = [key, maps]
204
183
  maps
205
184
  end
206
185
 
207
- def js_files_in_controller(dir, config)
208
- return [] unless Dir.exist?(dir)
209
-
210
- Dir.glob(File.join(dir, "**", config.extensions_glob))
211
- .reject { |f| File.directory?(f) }
212
- .sort
213
- end
214
-
215
- def extract_defined_symbols(file_path)
216
- content = File.read(file_path, encoding: "utf-8")
217
- SymbolExtractor.extract_definitions(content, file_path: file_path)
218
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
219
- []
220
- end
221
-
222
- def extract_used_component_symbols(file_path)
223
- content = File.read(file_path, encoding: "utf-8")
224
- SymbolExtractor.extract_usages(content, file_path: file_path)
225
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
226
- []
227
- end
228
-
229
186
  def resolve_bundle_reference(config, bundle_name)
230
187
  subdir = config.normalized_manifest_subdir
231
188
  manifest_path = File.join(config.abs_manifest_dir, "#{bundle_name}.js")
@@ -261,4 +218,3 @@ module ReactManifest
261
218
  end
262
219
  end
263
220
  end
264
- # rubocop:enable Metrics/ModuleLength
@@ -80,6 +80,22 @@ namespace :react_manifest do
80
80
  puts " ✓ #{written} written, #{unchanged} unchanged"
81
81
  end
82
82
 
83
+ # 6. Ensure generated manifests are gitignored
84
+ puts "\n6) Gitignoring generated manifests..."
85
+ if config.manage_gitignore?
86
+ patcher = ReactManifest::GitignorePatcher.new(config)
87
+ result = patcher.patch!
88
+ if result.appended
89
+ puts " ✓ added #{patcher.pattern} to .gitignore"
90
+ puts " → one-time cleanup for previously-committed manifests:"
91
+ puts " git rm --cached #{File.dirname(patcher.pattern)}/*.js"
92
+ else
93
+ puts " ✓ #{patcher.pattern} already ignored"
94
+ end
95
+ else
96
+ puts " (skipped — config.manage_gitignore is false)"
97
+ end
98
+
83
99
  # Done
84
100
  puts "\n=== Setup #{'(dry-run) ' if config.dry_run?}complete ==="
85
101
  puts "Start your Rails server — react_bundle_tag will serve the right JS per controller." unless config.dry_run?
@@ -120,6 +136,19 @@ namespace :react_manifest do
120
136
  end
121
137
  end
122
138
 
139
+ desc "Fail (exit 1, warnings on stderr) if any bundle declares the same top-level binding twice; writes nothing"
140
+ task check: :environment do
141
+ config = ReactManifest.configuration
142
+ next unless Dir.exist?(config.abs_ux_root)
143
+
144
+ clashes = ReactManifest::Generator.new(config).check
145
+ next if clashes.empty?
146
+
147
+ clashes.each { |message| warn "[ReactManifest] #{message}" }
148
+ warn "[ReactManifest] #{clashes.size} declaration clash(es) found."
149
+ exit 1
150
+ end
151
+
123
152
  desc "Print the JSX dependency map and warnings without writing any files"
124
153
  task analyze: :environment do
125
154
  config = ReactManifest.configuration
@@ -130,6 +159,14 @@ namespace :react_manifest do
130
159
  scan_result = scanner.scan(classification)
131
160
  dep_map = ReactManifest::DependencyMap.new(scan_result)
132
161
  dep_map.print_report
162
+
163
+ reasons = ReactManifest::Generator.new(config).promotion_reasons
164
+ unless reasons.empty?
165
+ puts "\nPromotions (app/ components emitted into #{config.shared_bundle}):"
166
+ reasons.sort.each do |req, bundles|
167
+ puts " #{req} <- used by: #{bundles.sort.join(', ')}"
168
+ end
169
+ end
133
170
  end
134
171
 
135
172
  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.34
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: mini_racer
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.2'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0.2'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: minitest
34
48
  requirement: !ruby/object:Gem::Requirement
@@ -119,20 +133,6 @@ dependencies:
119
133
  - - "~>"
120
134
  - !ruby/object:Gem::Version
121
135
  version: '3.0'
122
- - !ruby/object:Gem::Dependency
123
- name: mini_racer
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - ">="
127
- - !ruby/object:Gem::Version
128
- version: '0'
129
- type: :development
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - ">="
134
- - !ruby/object:Gem::Version
135
- version: '0'
136
136
  description: |
137
137
  react-manifest-rails automatically generates per-controller Sprockets manifest
138
138
  files for Rails applications using react-rails + Sprockets. It eliminates the
@@ -152,9 +152,11 @@ files:
152
152
  - lib/react_manifest/application_analyzer.rb
153
153
  - lib/react_manifest/application_migrator.rb
154
154
  - lib/react_manifest/ast_extractor.rb
155
+ - lib/react_manifest/component_index.rb
155
156
  - lib/react_manifest/configuration.rb
156
157
  - lib/react_manifest/dependency_map.rb
157
158
  - lib/react_manifest/generator.rb
159
+ - lib/react_manifest/gitignore_patcher.rb
158
160
  - lib/react_manifest/layout_patcher.rb
159
161
  - lib/react_manifest/logging.rb
160
162
  - lib/react_manifest/path_utils.rb