react-manifest-rails 0.3.0 → 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.3.0".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"
@@ -19,7 +20,6 @@ require "react_manifest/watcher"
19
20
  require "react_manifest/reporter"
20
21
  require "react_manifest/view_helpers"
21
22
 
22
- # rubocop:disable Metrics/ModuleLength
23
23
  module ReactManifest
24
24
  class << self
25
25
  def configuration
@@ -178,79 +178,11 @@ module ReactManifest
178
178
  return cached_maps if cached_key == key
179
179
  end
180
180
 
181
- controller_dirs = TreeClassifier.new(config).classify.controller_dirs
182
- symbol_to_bundle = {}
183
- bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
184
- bundle_files = Hash.new { |h, k| h[k] = [] }
185
- bundle_dependencies = Hash.new { |h, k| h[k] = Set.new }
186
-
187
- controller_dirs.each do |ctrl|
188
- bundle_name = ctrl[:bundle_name]
189
- files = js_files_in_controller(ctrl[:path], config)
190
- bundle_files[bundle_name].concat(files)
191
- isolated = config.isolated_app_dirs.include?(ctrl[:name])
192
-
193
- files.each do |file_path|
194
- extract_defined_symbols(file_path).each do |symbol|
195
- next unless symbol.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
196
-
197
- # Keep first writer to ensure deterministic behavior if a symbol is duplicated.
198
- # Isolated dirs never register into the shared symbol index (see
199
- # Generator#build_controller_context for the full rationale).
200
- symbol_to_bundle[symbol] ||= bundle_name unless isolated
201
- bundle_own_symbols[bundle_name] << symbol
202
- end
203
- end
204
- end
205
-
206
- bundle_files.each do |bundle_name, files|
207
- own_symbols = bundle_own_symbols[bundle_name]
208
-
209
- files.each do |file_path|
210
- extract_used_component_symbols(file_path).each do |symbol|
211
- # A symbol the bundle defines itself is always satisfied locally —
212
- # never attribute it to another bundle just because that symbol
213
- # name happens to collide with something defined elsewhere.
214
- next if own_symbols.include?(symbol)
215
-
216
- dep_bundle = symbol_to_bundle[symbol]
217
- next unless dep_bundle && dep_bundle != bundle_name
218
-
219
- bundle_dependencies[bundle_name] << dep_bundle
220
- end
221
- end
222
- end
223
-
224
- maps = {
225
- symbol_to_bundle: symbol_to_bundle,
226
- bundle_dependencies: bundle_dependencies
227
- }
181
+ maps = ComponentIndex.maps(config)
228
182
  @component_maps_cache = [key, maps]
229
183
  maps
230
184
  end
231
185
 
232
- def js_files_in_controller(dir, config)
233
- return [] unless Dir.exist?(dir)
234
-
235
- Dir.glob(File.join(dir, "**", config.extensions_glob))
236
- .reject { |f| File.directory?(f) }
237
- .sort
238
- end
239
-
240
- def extract_defined_symbols(file_path)
241
- content = File.read(file_path, encoding: "utf-8")
242
- SymbolExtractor.extract_definitions(content, file_path: file_path)
243
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
244
- []
245
- end
246
-
247
- def extract_used_component_symbols(file_path)
248
- content = File.read(file_path, encoding: "utf-8")
249
- SymbolExtractor.extract_usages(content, file_path: file_path)
250
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
251
- []
252
- end
253
-
254
186
  def resolve_bundle_reference(config, bundle_name)
255
187
  subdir = config.normalized_manifest_subdir
256
188
  manifest_path = File.join(config.abs_manifest_dir, "#{bundle_name}.js")
@@ -286,4 +218,3 @@ module ReactManifest
286
218
  end
287
219
  end
288
220
  end
289
- # rubocop:enable Metrics/ModuleLength
@@ -136,6 +136,19 @@ namespace :react_manifest do
136
136
  end
137
137
  end
138
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
+
139
152
  desc "Print the JSX dependency map and warnings without writing any files"
140
153
  task analyze: :environment do
141
154
  config = ReactManifest.configuration
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.3.0
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,6 +152,7 @@ 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