react-manifest-rails 0.3.0 → 0.3.2

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.2".freeze
3
3
  end
@@ -10,33 +10,49 @@ 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)
21
+ request_store["react_manifest.bundle_tag_rendered"] = true
22
+
15
23
  # controller_path is not available in all view contexts (mailers, engines,
16
24
  # views rendered outside a request). Fall back gracefully rather than raising.
17
25
  ctrl = respond_to?(:controller_path, true) ? controller_path : nil
18
26
  return "".html_safe if ctrl.nil?
19
27
 
20
- bundles = ReactManifest.resolve_bundles(ctrl)
28
+ emitted = emitted_bundles
29
+ hoisted = hoist_inline_bundle_tags
30
+ emitted.reject! { |e| hoisted.any? { |h| canonical_bundle_name(h) == canonical_bundle_name(e) } }
31
+
32
+ bundles = (ReactManifest.resolve_bundles(ctrl) + hoisted).uniq { |b| canonical_bundle_name(b) }
21
33
  return "".html_safe if bundles.empty?
22
34
 
23
35
  # Record emitted bundles so react_component doesn't re-emit them.
24
- emitted = emitted_bundles
25
36
  fresh_bundles = bundles.reject { |b| emitted_bundle?(emitted, b) }
26
37
  return "".html_safe if fresh_bundles.empty?
27
38
 
28
39
  fresh_bundles.each { |b| emitted << b }
29
40
 
30
- asset_names = fresh_bundles.map { |bundle| "#{bundle}.js" }
31
- javascript_include_tag(*asset_names, extname: false, **html_options)
41
+ tags = fresh_bundles.map do |bundle|
42
+ javascript_include_tag("#{bundle}.js", extname: false, **bundle_tag_options(bundle, html_options))
43
+ end
44
+ safe_join(tags, "\n")
32
45
  end
33
46
 
34
47
  # react-rails integration:
35
48
  # If a component-specific controller bundle can be inferred from the component
36
49
  # symbol, include that bundle at the call site before delegating to react-rails.
37
50
  # This avoids strict dependence on controller_path -> bundle naming alignment.
51
+ #
52
+ # A call after react_bundle_tag is in the layout body (page chrome), so its root gets
53
+ # data-react-persist: a Turbolinks wrapper keeps it mounted and hands it new props.
38
54
  def react_component(*args, **kwargs, &block)
39
- html = super
55
+ html = bundle_tag_rendered? ? super(*persistent_args(args, kwargs), &block) : super
40
56
 
41
57
  component_name = args.first
42
58
  bundles = ReactManifest.resolve_bundles_for_component_direct(component_name)
@@ -48,7 +64,7 @@ module ReactManifest
48
64
  next if emitted_bundle?(emitted, bundle)
49
65
 
50
66
  emitted << bundle
51
- javascript_include_tag("#{bundle}.js", extname: false)
67
+ javascript_include_tag("#{bundle}.js", extname: false, data: { react_manifest: bundle })
52
68
  end
53
69
 
54
70
  return html if new_tags.empty?
@@ -58,6 +74,44 @@ module ReactManifest
58
74
 
59
75
  private
60
76
 
77
+ def bundle_tag_rendered?
78
+ request_store["react_manifest.bundle_tag_rendered"]
79
+ end
80
+
81
+ # react-rails takes (name, props = {}, options = {}); trailing keywords fill the next slot.
82
+ def persistent_args(args, kwargs)
83
+ name, props, options = kwargs.empty? ? args : [*args, kwargs]
84
+ options = { tag: options } if options.is_a?(Symbol)
85
+ options ||= {}
86
+ [name, props || {}, options.merge(data: { react_persist: true }.merge(options[:data] || {}))]
87
+ end
88
+
89
+ # Turbolinks 5 full-reloads when the tracked head elements differ between pages.
90
+ # ux_shared is identical on every page, so tracking it forces one reload per deploy;
91
+ # tracking controller bundles would reload on every cross-controller visit.
92
+ def bundle_tag_options(bundle, html_options)
93
+ shared = canonical_bundle_name(ReactManifest.configuration.shared_bundle)
94
+ return html_options unless canonical_bundle_name(bundle) == shared
95
+
96
+ html_options.merge(data: (html_options[:data] || {}).merge(turbolinks_track: "reload"))
97
+ end
98
+
99
+ # Strips the inline tags react_component already wrote into the rendered
100
+ # template (and content_for) buffers; returns their bundle names in order.
101
+ def hoist_inline_bundle_tags
102
+ return [] unless respond_to?(:view_flow, true) && view_flow
103
+
104
+ names = []
105
+ view_flow.content.each_key do |key|
106
+ html = view_flow.get(key).to_s
107
+ next unless html.include?("data-react-manifest")
108
+
109
+ names.concat(html.scan(INLINE_BUNDLE_TAG).flatten)
110
+ view_flow.set(key, html.gsub(INLINE_BUNDLE_TAG, "").html_safe)
111
+ end
112
+ names
113
+ end
114
+
61
115
  def emitted_bundle?(emitted, bundle)
62
116
  canonical = canonical_bundle_name(bundle)
63
117
  emitted.any? { |existing| canonical_bundle_name(existing) == canonical }
@@ -68,12 +122,16 @@ module ReactManifest
68
122
  end
69
123
 
70
124
  def emitted_bundles
71
- # ActionView can instantiate multiple helper contexts during one request.
72
- # Store emitted bundles in request env so layout + template helpers dedupe.
125
+ request_store["react_manifest.emitted_bundles"] ||= []
126
+ end
127
+
128
+ # ActionView can instantiate multiple helper contexts during one request.
129
+ # Request env is shared by layout + template helpers; fall back to the view itself.
130
+ def request_store
73
131
  if respond_to?(:request, true) && request
74
- request.env["react_manifest.emitted_bundles"] ||= []
132
+ request.env
75
133
  else
76
- @emitted_bundles ||= []
134
+ @request_store ||= {}
77
135
  end
78
136
  end
79
137
  end
@@ -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.2
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