react-manifest-rails 0.2.34 → 0.3.0

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: 0076f3ffaf1c303d4ac14a8345477b3851045267b0cba8f823f49ce5f17efa4f
4
- data.tar.gz: 579d04e786fe515e46d9c56c5c755efcf616cfd49289fcef8ccda0cc4cce7fa8
3
+ metadata.gz: 42ca130740decc2c19123ab8e2d0b368925f0bd988736d84a9087d4016e1836c
4
+ data.tar.gz: e9e23a7c0eb8dea12f4eb8498f08bff0ea11fe56f168833ae3ef13e090b4d59a
5
5
  SHA512:
6
- metadata.gz: 6d221314836bc176da1378824e2ab7a55232d5977db2ca46b353d98f499b8e49d31b7999d0787953944db9c8b1c8652f3f17422918ee59d0fd5b78f5d46dca65
7
- data.tar.gz: 3eeaba00d80ad2e7c73b1320026ea4b3ed0ed989b5a3c33513a2e45f6e63f7b632b466c2cb192e163b42892f4bce76b2dc59b87b90d90869c4eaa5137f4a8a86
6
+ metadata.gz: d73c047a2257f229e26682658c2644c0645fd362ba30f2f2ef34fceb15ecf83cf282b2c7899472e3eb25351b30512b5a3e8dbe456e1057502453876f2ce1868d
7
+ data.tar.gz: 78ebfd5b3390fa6204d6a59a46d94f1aef87c87359b499049f745da68d493f2dab3dd4fefe1f46667ca1be40f66c02e17fa1b977f3fc5b67370a9ba37a9b0566
data/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-15
11
+
12
+ ### Fixed
13
+ - `always_include` no longer double-declares. Its bundles are delivered on every page
14
+ by their own `<script>` tag (both `react_bundle_tag`/`resolve_bundles` and
15
+ `react_component` now emit them), and their files are no longer inlined into each
16
+ controller manifest. Previously the separate tag plus the inline loaded an
17
+ always_include bundle's **private** files twice on any other controller's page, and a
18
+ **collided** symbol whose definer lives in an always_include bundle could pull a
19
+ second definer into the consumer — both causing "Identifier X has already been
20
+ declared". Controller dependency resolution now treats symbols provided by an
21
+ always_include bundle as already satisfied, and skips those bundles when inlining
22
+ dependencies (legacy `auto_shared = false` path included). The "already provided"
23
+ set is computed from the exact files the always_include `<script>` tag actually
24
+ carries (its own non-promoted files plus the canonical dependency files it inlines),
25
+ not from whole dependency bundles — so a consumer never drops a definer for a symbol
26
+ that lives in a sibling file the tag does not load. Symbol availability that the
27
+ previous inlining protected (added in 0.2.24) is preserved by the tag `react_component`
28
+ now emits.
29
+ - One case remains inherently unresolvable by dependency arithmetic: when an
30
+ `always_include` bundle *consumes* a collided symbol, its tag inlines the
31
+ first-writer definer, which also lives in that definer's own bundle manifest, so on
32
+ the definer's own page the file loads twice. `react_manifest:generate` now emits a
33
+ warning naming the always_include bundle, the duplicated file/symbol, and the owning
34
+ bundle, with the fix (rename or relocate the collided symbol). Detected in both the
35
+ default and legacy (`auto_shared = false`) modes.
36
+
37
+ ### Added
38
+ - Auto-promotion: a component defined under `ux/app/<controller>/` but used by any
39
+ other bundle is now emitted into `ux_shared` (loaded once per page) instead of being
40
+ inlined into each consumer. This eliminates duplicate top-level `const` declarations
41
+ ("Identifier X has already been declared") — and the cascading `X is not defined`
42
+ errors that follow when a SyntaxError aborts a concatenated bundle — for components
43
+ shared across controllers (e.g. a navbar component pulled in via `always_include`).
44
+ Files never move; only the `//= require` line's destination changes. Promotion is
45
+ transitive. `react_manifest:analyze` now lists what was promoted and why. Set
46
+ `config.auto_shared = false` to restore the previous inline behavior.
47
+
48
+ ### Changed
49
+ - Generated `ux_manifests/*.js` are now treated as build artifacts. `react_manifest:setup`
50
+ and the development boot step add them to `.gitignore` (idempotently) and keep a
51
+ committed `.keep` so the directory always exists; setup prints a one-time
52
+ `git rm --cached` to untrack previously-committed manifests. Production is unaffected
53
+ (manifests regenerate during `assets:precompile`). Opt out with
54
+ `config.manage_gitignore = false`. Upgrading an existing app requires no manual step:
55
+ the first dev boot regenerates manifests and ensures the ignore entry.
56
+
10
57
  ## [0.2.34] - 2026-07-02
11
58
 
12
59
  ### Added
@@ -21,9 +21,18 @@ module ReactManifest
21
21
  # Keeping generated files out of output_dir root avoids clutter.
22
22
  attr_accessor :manifest_subdir
23
23
 
24
+ # When true (default), the gem ensures the generated manifest dir is gitignored
25
+ # (adds the entry on dev boot if missing). Set false to manage .gitignore yourself.
26
+ attr_accessor :manage_gitignore
27
+
24
28
  # Bundle name for auto-generated shared bundle (all non-app/ dirs)
25
29
  attr_accessor :shared_bundle
26
30
 
31
+ # When true (default), a component defined under app_dir but used by any other
32
+ # bundle is emitted into the shared bundle (loaded once per page) instead of
33
+ # being inlined into each consumer. Set false to restore legacy inlining.
34
+ attr_accessor :auto_shared
35
+
27
36
  # Bundles always prepended by react_bundle_tag (e.g. ["ux_main"])
28
37
  attr_accessor :always_include
29
38
 
@@ -101,7 +110,9 @@ module ReactManifest
101
110
  @app_dir = "app"
102
111
  @output_dir = "app/assets/javascripts"
103
112
  @manifest_subdir = "ux_manifests"
113
+ @manage_gitignore = true
104
114
  @shared_bundle = "ux_shared"
115
+ @auto_shared = true
105
116
  @always_include = []
106
117
  @ignore = []
107
118
  @isolated_app_dirs = []
@@ -119,6 +130,10 @@ module ReactManifest
119
130
  !!@dry_run
120
131
  end
121
132
 
133
+ def auto_shared?
134
+ !!@auto_shared
135
+ end
136
+
122
137
  def verbose?
123
138
  !!@verbose
124
139
  end
@@ -127,6 +142,10 @@ module ReactManifest
127
142
  !!@stdout_logging
128
143
  end
129
144
 
145
+ def manage_gitignore?
146
+ !!@manage_gitignore
147
+ end
148
+
130
149
  # Glob fragment used by Dir.glob, e.g. "*.{js,jsx}" or "*.{js,jsx,ts,tsx}"
131
150
  def extensions_glob
132
151
  "*.{#{extensions.join(',')}}"
@@ -42,6 +42,14 @@ module ReactManifest
42
42
  @classifier = TreeClassifier.new(config)
43
43
  end
44
44
 
45
+ # After a run (or on demand), maps a promoted file's require path to the set of
46
+ # bundle names that forced its promotion. Used by react_manifest:analyze.
47
+ def promotion_reasons
48
+ classification = @classifier.classify
49
+ build_controller_context(classification.controller_dirs, classification.shared_dirs)
50
+ @promotion_reasons || {}
51
+ end
52
+
45
53
  # Run full generation. Returns array of {path:, status:} hashes.
46
54
  #
47
55
  # All manifest content is built first (no filesystem writes), then written
@@ -49,10 +57,10 @@ module ReactManifest
49
57
  # written and others stale/missing.
50
58
  def run!
51
59
  classification = @classifier.classify
52
- controller_context = build_controller_context(classification.controller_dirs)
60
+ controller_context = build_controller_context(classification.controller_dirs, classification.shared_dirs)
53
61
 
54
62
  # Phase 1: build all content in memory — no I/O.
55
- shared_manifest = build_shared(classification.shared_dirs)
63
+ shared_manifest = build_shared(classification.shared_dirs, controller_context[:promoted_files])
56
64
  manifests = [shared_manifest] + classification.controller_dirs.map do |ctrl|
57
65
  build_controller(ctrl, controller_context)
58
66
  end
@@ -98,10 +106,9 @@ module ReactManifest
98
106
 
99
107
  # ------------------------------------------------------------------ shared
100
108
 
101
- def build_shared(shared_dirs)
109
+ def build_shared(shared_dirs, promoted_files = Set.new)
102
110
  lines = header_lines
103
- reqs = shared_dirs
104
- .flat_map { |d| js_files_in(d[:path]) }
111
+ reqs = (shared_dirs.flat_map { |d| js_files_in(d[:path]) } + promoted_files.to_a)
105
112
  .map { |f| normalize_require_path(relative_require_path(f)) }
106
113
  .uniq
107
114
  .sort
@@ -119,13 +126,21 @@ module ReactManifest
119
126
 
120
127
  def build_controller(ctrl, controller_context)
121
128
  lines = header_lines
122
- always_include_reqs = controller_context[:always_include_requires].fetch(ctrl[:bundle_name], [])
123
- dep_requires = controller_dependency_requires(ctrl[:bundle_name], controller_context)
124
- ext_reqs = controller_context[:external_requires].fetch(ctrl[:bundle_name], Set.new).to_a.sort
125
-
126
- files = js_files_in(ctrl[:path])
129
+ promoted = controller_context[:promoted_files]
130
+ bundle_name = ctrl[:bundle_name]
131
+ # always_include bundles are NOT inlined here: resolve_bundles /
132
+ # react_component deliver them on every page via their own <script> tag,
133
+ # so inlining would load the same file twice and double-declare.
134
+ dep_requires = if @config.auto_shared?
135
+ promoted_aware_dependency_requires(bundle_name, controller_context)
136
+ else
137
+ controller_dependency_requires(bundle_name, controller_context)
138
+ end
139
+ ext_reqs = controller_context[:external_requires].fetch(bundle_name, Set.new).to_a.sort
140
+
141
+ files = js_files_in(ctrl[:path]).reject { |f| promoted.include?(f) }
127
142
  own_requires = files.map { |f| relative_require_path(f) }
128
- all_requires = (always_include_reqs + dep_requires + ext_reqs + own_requires).uniq
143
+ all_requires = (dep_requires + ext_reqs + own_requires).uniq
129
144
 
130
145
  if all_requires.empty?
131
146
  lines << "// (no JSX files found in #{ctrl[:name]}/)"
@@ -136,66 +151,303 @@ module ReactManifest
136
151
  { filename: "#{ctrl[:bundle_name]}.js", content: "#{lines.join("\n")}\n" }
137
152
  end
138
153
 
139
- def build_controller_context(controller_dirs)
154
+ def build_controller_context(controller_dirs, shared_dirs)
155
+ bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
156
+ symbol_definers, bundle_own_symbols = index_controller_symbols(controller_dirs)
157
+
158
+ file_uses = Hash.new { |h, k| h[k] = Set.new }
159
+ symbol_used_by_bundles = Hash.new { |h, k| h[k] = Set.new }
160
+ record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
161
+
162
+ collided = symbol_definers.each_with_object(Set.new) do |(sym, files), acc|
163
+ acc << sym if files.size >= 2
164
+ end
165
+
166
+ external_symbol_to_require = index_external_symbols(symbol_to_bundle)
167
+
168
+ dependencies, external_requires = build_dependency_graph(
169
+ bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle, external_symbol_to_require
170
+ )
171
+
172
+ symbol_index = {
173
+ file_defs: file_defs,
174
+ symbol_to_file: symbol_to_file,
175
+ symbol_used_by_bundles: symbol_used_by_bundles
176
+ }
177
+ promoted_files =
178
+ if @config.auto_shared?
179
+ compute_promoted_files(file_owner, file_uses, symbol_index, collided)
180
+ else
181
+ Set.new
182
+ end
183
+
184
+ context = {
185
+ bundle_files: bundle_files,
186
+ dependencies: dependencies,
187
+ external_requires: external_requires,
188
+ promoted_files: promoted_files,
189
+ file_uses: file_uses,
190
+ symbol_to_file: symbol_to_file,
191
+ bundle_own_symbols: bundle_own_symbols
192
+ }
193
+
194
+ # Bundles guaranteed loaded on every page by always_include (used to exclude
195
+ # them from the legacy whole-bundle inline path and to self-exclude an
196
+ # always_include bundle's own manifest from the skip below).
197
+ context[:always_loaded_bundles] = compute_always_loaded_bundles(dependencies)
198
+ # Symbols actually delivered on every page by the always_include <script>
199
+ # tag(s) — derived from the real files those tags carry, NOT from whole dep
200
+ # bundles (which would over-claim symbols the tag does not load and cause a
201
+ # consumer to skip a definer it still needs). Unused by the legacy path.
202
+ context[:always_provided_symbols] =
203
+ @config.auto_shared? ? compute_always_provided_symbols(context, file_defs) : Set.new
204
+
205
+ warn_on_always_include_collisions(context, file_owner, file_defs, collided)
206
+
207
+ context
208
+ end
209
+
210
+ # Surface the one case dependency arithmetic cannot resolve: an always_include
211
+ # bundle that CONSUMES a collided symbol. Its tag inlines the first-writer
212
+ # definer file, which also lives in that file's own bundle manifest — so on the
213
+ # definer's own page the file loads twice (once via the always_include tag,
214
+ # once via the owning bundle), a duplicate declaration. We cannot auto-fix it
215
+ # (there is no collision-free page-global home for a collided symbol), so we
216
+ # warn with the actionable fix: rename or relocate the symbol.
217
+ def warn_on_always_include_collisions(context, file_owner, file_defs, collided)
218
+ return if collided.empty?
219
+
220
+ bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
221
+ bundles.each do |always_bundle|
222
+ always_include_foreign_files(always_bundle, context).each do |file|
223
+ owner = file_owner[file]
224
+ next if owner.nil? || owner == always_bundle
225
+
226
+ collided_syms = (file_defs.fetch(file, Set.new) & collided).to_a.sort
227
+ next if collided_syms.empty?
228
+
229
+ plural = collided_syms.size > 1 ? "s" : ""
230
+ log_warn "always_include bundle '#{always_bundle}' pulls in " \
231
+ "'#{relative_require_path(file)}' (defines collided symbol#{plural} " \
232
+ "#{collided_syms.join(', ')}) owned by '#{owner}'. On #{owner}'s own page " \
233
+ "that file loads twice — once via the #{always_bundle} tag and once via " \
234
+ "#{owner} — causing a duplicate declaration. Rename the symbol or move it " \
235
+ "to a uniquely-named shared component."
236
+ end
237
+ end
238
+ end
239
+
240
+ # Files an always_include bundle's tag delivers from OTHER bundles: under
241
+ # auto_shared, the exact non-promoted dependency files it inlines; in legacy
242
+ # mode, every file of the dependency bundles it inlines wholesale.
243
+ def always_include_foreign_files(always_bundle, context)
244
+ if @config.auto_shared?
245
+ promoted_aware_dependency_files(always_bundle, context, Set.new)
246
+ else
247
+ transitive_dependencies(always_bundle, context[:dependencies])
248
+ .flat_map { |dep| context[:bundle_files].fetch(dep, []) }
249
+ end
250
+ end
251
+
252
+ # Symbols the always_include tag(s) put on every page: for each always_include
253
+ # bundle, the symbols defined by its own non-promoted files plus the canonical
254
+ # dependency files it inlines (its manifest's exact file closure).
255
+ def compute_always_provided_symbols(context, file_defs)
256
+ bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
257
+ return Set.new if bundles.empty?
258
+
259
+ always_files = Set.new
260
+ bundles.each do |b|
261
+ context[:bundle_files].fetch(b, []).each do |file|
262
+ always_files << file unless context[:promoted_files].include?(file)
263
+ end
264
+ promoted_aware_dependency_files(b, context, Set.new).each { |file| always_files << file }
265
+ end
266
+
267
+ always_files.each_with_object(Set.new) { |file, syms| syms.merge(file_defs.fetch(file, Set.new)) }
268
+ end
269
+
270
+ # Files that can never be promoted to ux_shared: any file defining a "collided"
271
+ # symbol (a name with 2+ app-dir definers — promoting one copy into the always-
272
+ # loaded shared bundle would double-declare against the other copy on its page),
273
+ # plus any file that transitively depends on such a file (it can't live in shared
274
+ # if a dependency can't). These fall back to legacy per-consumer inlining.
275
+ def unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
276
+ unpromotable = Set.new
277
+ file_owner.each_key do |file_path|
278
+ unpromotable << file_path if file_defs[file_path].any? { |s| collided.include?(s) }
279
+ end
280
+
281
+ loop do
282
+ added = false
283
+ file_owner.each_key do |file_path|
284
+ next if unpromotable.include?(file_path)
285
+
286
+ taints = file_uses.fetch(file_path, Set.new).any? do |sym|
287
+ dep = symbol_to_file[sym]
288
+ dep && unpromotable.include?(dep)
289
+ end
290
+ if taints
291
+ unpromotable << file_path
292
+ added = true
293
+ end
294
+ end
295
+ break unless added
296
+ end
297
+
298
+ unpromotable
299
+ end
300
+
301
+ # A controller file is promoted to the shared bundle when a symbol it canonically
302
+ # defines is used by a bundle other than its owner, then transitively for anything
303
+ # a promoted file depends on. Collided/tainted files (see unpromotable_files) are
304
+ # never promoted. Records why each file was promoted for react_manifest:analyze.
305
+ #
306
+ # symbol_index bundles {file_defs:, symbol_to_file:, symbol_used_by_bundles:} into
307
+ # one param so this stays under Metrics/ParameterLists (rubocop flags 6 positional
308
+ # args) without changing the underlying algorithm.
309
+ def compute_promoted_files(file_owner, file_uses, symbol_index, collided)
310
+ file_defs = symbol_index[:file_defs]
311
+ symbol_to_file = symbol_index[:symbol_to_file]
312
+ symbol_used_by_bundles = symbol_index[:symbol_used_by_bundles]
313
+ unpromotable = unpromotable_files(file_owner, file_defs, file_uses, symbol_to_file, collided)
314
+ promoted = Set.new
315
+ reasons = Hash.new { |h, k| h[k] = Set.new }
316
+
317
+ file_owner.each do |file_path, owner|
318
+ next if unpromotable.include?(file_path)
319
+
320
+ forcing = Set.new
321
+ file_defs[file_path].each do |sym|
322
+ # canonical-definer guard: only the file that canonically defines the
323
+ # symbol is eligible (resolves collisions to one file; excludes isolated
324
+ # dirs, which are never registered in symbol_to_file).
325
+ next unless symbol_to_file[sym] == file_path
326
+
327
+ (symbol_used_by_bundles[sym] - [owner]).each { |b| forcing << b }
328
+ end
329
+ next if forcing.empty?
330
+
331
+ promoted << file_path
332
+ reasons[relative_require_path(file_path)] = forcing
333
+ end
334
+
335
+ worklist = promoted.to_a
336
+ until worklist.empty?
337
+ current = worklist.pop
338
+ file_uses.fetch(current, Set.new).each do |sym|
339
+ dep_file = symbol_to_file[sym]
340
+ next unless dep_file
341
+ next if unpromotable.include?(dep_file)
342
+ next if promoted.include?(dep_file)
343
+
344
+ promoted << dep_file
345
+ reasons[relative_require_path(dep_file)] << "(transitive)"
346
+ worklist << dep_file
347
+ end
348
+ end
349
+
350
+ @promotion_reasons = reasons.transform_values(&:to_a)
351
+ promoted
352
+ end
353
+
354
+ # Indexes each controller dir's files and the PascalCase symbols they define.
355
+ # Returns [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
356
+ # symbol_definers, bundle_own_symbols].
357
+ def index_controller_symbols(controller_dirs)
140
358
  bundle_files = {}
359
+ file_owner = {}
360
+ file_defs = Hash.new { |h, k| h[k] = Set.new }
141
361
  symbol_to_bundle = {}
362
+ symbol_to_file = {}
363
+ symbol_definers = Hash.new { |h, k| h[k] = Set.new }
142
364
  bundle_own_symbols = Hash.new { |h, k| h[k] = Set.new }
143
- external_symbol_to_require = {}
144
- dependencies = Hash.new { |h, k| h[k] = Set.new }
145
- external_requires = Hash.new { |h, k| h[k] = Set.new }
146
365
 
147
366
  controller_dirs.each do |ctrl|
148
- # Index controller-defined symbols for cross-app detection
149
367
  bundle_name = ctrl[:bundle_name]
150
368
  files = js_files_in(ctrl[:path])
151
369
  bundle_files[bundle_name] = files
152
-
153
370
  isolated = @config.isolated_app_dirs.include?(ctrl[:name])
154
371
 
155
372
  files.each do |file_path|
373
+ file_owner[file_path] = bundle_name
156
374
  extract_defined_symbols(file_path).each do |sym|
157
375
  next unless sym.match?(/\A[A-Z][A-Za-z0-9_]*\z/)
158
376
 
159
- # Isolated dirs never register into the shared symbol index, so
160
- # no other bundle can ever be inferred to depend on them —
161
- # regardless of what their own symbol names happen to collide
162
- # with (regex-based usage detection can't tell a real component
163
- # reference from an unrelated word appearing as plain JSX text).
164
- symbol_to_bundle[sym] ||= bundle_name unless isolated
377
+ file_defs[file_path] << sym
378
+ # Isolated dirs never register into the shared symbol index, so no
379
+ # other bundle can ever be inferred to depend on them.
380
+ unless isolated
381
+ symbol_to_bundle[sym] ||= bundle_name
382
+ symbol_to_file[sym] ||= file_path
383
+ symbol_definers[sym] << file_path
384
+ end
165
385
  bundle_own_symbols[bundle_name] << sym
166
386
  end
167
387
  end
168
388
  end
169
389
 
170
- # Index symbols from external_roots dirs
390
+ [bundle_files, file_owner, file_defs, symbol_to_bundle, symbol_to_file,
391
+ symbol_definers, bundle_own_symbols]
392
+ end
393
+
394
+ # Records, per controller file and per bundle, which symbols are used —
395
+ # plus a pseudo-bundle entry (config.shared_bundle) for any controller
396
+ # symbol referenced from a shared-dir file (shared code loads on every
397
+ # page, so that symbol's owning file must be treated as externally used).
398
+ def record_symbol_usages(bundle_files, shared_dirs, file_uses, symbol_used_by_bundles)
399
+ bundle_files.each do |bundle_name, files|
400
+ files.each do |file_path|
401
+ extract_used_component_symbols(file_path).each do |sym|
402
+ file_uses[file_path] << sym
403
+ symbol_used_by_bundles[sym] << bundle_name
404
+ end
405
+ end
406
+ end
407
+
408
+ shared_dirs.each do |dir|
409
+ js_files_in(dir[:path]).each do |file_path|
410
+ extract_used_component_symbols(file_path).each do |sym|
411
+ symbol_used_by_bundles[sym] << @config.shared_bundle
412
+ end
413
+ end
414
+ end
415
+ end
416
+
417
+ # Indexes symbols from external_roots dirs, then lets explicit
418
+ # external_providers win over scanned roots on symbol conflicts.
419
+ def index_external_symbols(symbol_to_bundle)
420
+ external_symbol_to_require = {}
421
+
171
422
  @config.external_roots.each do |root_path|
172
423
  abs_root = abs_external_root(root_path)
173
424
  external_js_files_in(abs_root).each do |file_path|
174
425
  req_path = relative_require_path(file_path)
175
-
176
426
  warn_on_external_controller_references(file_path, symbol_to_bundle)
177
-
178
427
  extract_defined_symbols(file_path).each do |sym|
179
428
  external_symbol_to_require[sym] ||= req_path
180
429
  end
181
430
  end
182
431
  end
183
432
 
184
- # Explicit external_providers win over scanned roots on symbol conflicts
185
433
  @config.external_providers.each do |sym, req_path|
186
434
  external_symbol_to_require[sym] = req_path
187
435
  end
188
436
 
189
- # Compute per-bundle cross-app and external dependencies
437
+ external_symbol_to_require
438
+ end
439
+
440
+ # Cross-app dependency graph + external requires (dependencies kept for
441
+ # reporting). Returns [dependencies, external_requires].
442
+ def build_dependency_graph(bundle_files, bundle_own_symbols, file_uses, symbol_to_bundle,
443
+ external_symbol_to_require)
444
+ dependencies = Hash.new { |h, k| h[k] = Set.new }
445
+ external_requires = Hash.new { |h, k| h[k] = Set.new }
446
+
190
447
  bundle_files.each do |bundle_name, files|
191
448
  own_symbols = bundle_own_symbols[bundle_name]
192
-
193
449
  files.each do |file_path|
194
- extract_used_component_symbols(file_path).each do |sym|
195
- # A symbol the bundle defines itself (in any of its own files) is
196
- # always satisfied locally — never attribute it to another bundle
197
- # or an external provider just because that symbol name happens
198
- # to collide with something defined elsewhere.
450
+ file_uses[file_path].each do |sym|
199
451
  next if own_symbols.include?(sym)
200
452
 
201
453
  dep_bundle = symbol_to_bundle[sym]
@@ -207,24 +459,82 @@ module ReactManifest
207
459
  end
208
460
  end
209
461
 
210
- always_include_requires = build_always_include_requires(bundle_files, dependencies)
462
+ [dependencies, external_requires]
463
+ end
211
464
 
212
- {
213
- bundle_files: bundle_files,
214
- dependencies: dependencies,
215
- always_include_requires: always_include_requires,
216
- external_requires: external_requires
217
- }
465
+ # Under auto_shared, single-definer cross-used files live in ux_shared, so a
466
+ # controller only needs the canonical files for the symbols it uses that are NOT
467
+ # promoted (collided names kept bundle-local, plus their transitive needs).
468
+ # File-level + transitive so we never over-inline a whole dependency bundle
469
+ # (which could double-declare against an always_include bundle's own copy).
470
+ def promoted_aware_dependency_requires(bundle_name, controller_context)
471
+ # Symbols already on every page via an always_include tag. Skipped so a
472
+ # controller never inlines a (possibly colliding) second definer. Disabled
473
+ # when building an always_include bundle's own manifest (it needs its deps).
474
+ always_provided = always_provided_for(bundle_name, controller_context)
475
+ promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
476
+ .map { |f| relative_require_path(f) }
477
+ .uniq
478
+ .sort
479
+ end
480
+
481
+ # Absolute paths of the non-promoted files a bundle must inline: for each
482
+ # symbol it uses (transitively) that is neither its own, promoted to shared,
483
+ # nor +always_provided+ on the page, the canonical (first-writer) definer file.
484
+ def promoted_aware_dependency_files(bundle_name, controller_context, always_provided)
485
+ own = controller_context[:bundle_own_symbols].fetch(bundle_name, Set.new)
486
+ promoted = controller_context[:promoted_files]
487
+ file_uses = controller_context[:file_uses]
488
+ sym2file = controller_context[:symbol_to_file]
489
+ own_files = controller_context[:bundle_files].fetch(bundle_name, [])
490
+
491
+ needed = Set.new
492
+ seen = Set.new
493
+ worklist = own_files.flat_map { |f| file_uses.fetch(f, Set.new).to_a }
494
+
495
+ until worklist.empty?
496
+ sym = worklist.pop
497
+ next if seen.include?(sym)
498
+
499
+ seen << sym
500
+ next if own.include?(sym)
501
+ next if always_provided.include?(sym)
502
+
503
+ dep_file = sym2file[sym]
504
+ next unless dep_file
505
+ next if promoted.include?(dep_file) # already served by ux_shared
506
+ next if own_files.include?(dep_file) # own file, added via own_requires
507
+ next if needed.include?(dep_file)
508
+
509
+ needed << dep_file
510
+ file_uses.fetch(dep_file, Set.new).each { |s| worklist << s unless seen.include?(s) }
511
+ end
512
+
513
+ needed
218
514
  end
219
515
 
220
516
  def controller_dependency_requires(bundle_name, controller_context)
221
517
  deps = transitive_dependencies(bundle_name, controller_context[:dependencies])
518
+ # Drop always_include bundles (and their transitive deps): they load on
519
+ # every page via their own <script> tag, so inlining them here would
520
+ # double-declare. Kept when the current bundle IS an always_include bundle.
521
+ always = controller_context[:always_loaded_bundles]
522
+ deps -= always.to_a unless always.include?(bundle_name)
222
523
  deps.flat_map { |dep_bundle| controller_context[:bundle_files].fetch(dep_bundle, []) }
223
524
  .map { |abs_path| relative_require_path(abs_path) }
224
525
  .uniq
225
526
  .sort
226
527
  end
227
528
 
529
+ # Symbols delivered on every page by an always_include tag, for +bundle_name+.
530
+ # Empty when building an always_include bundle's own manifest so it still
531
+ # inlines the dependencies it needs.
532
+ def always_provided_for(bundle_name, controller_context)
533
+ return Set.new if controller_context[:always_loaded_bundles].include?(bundle_name)
534
+
535
+ controller_context[:always_provided_symbols]
536
+ end
537
+
228
538
  def transitive_dependencies(bundle_name, dependency_map)
229
539
  ordered = []
230
540
  visiting = Set.new
@@ -245,30 +555,16 @@ module ReactManifest
245
555
  ordered
246
556
  end
247
557
 
248
- def build_always_include_requires(bundle_files, dependencies)
558
+ # The set of bundles guaranteed loaded on every page by always_include: each
559
+ # configured always_include bundle plus its transitive dependencies. These
560
+ # arrive via their own <script> tag (resolve_bundles / react_component), so
561
+ # controller manifests must never inline their files or symbols.
562
+ def compute_always_loaded_bundles(dependencies)
249
563
  bundles = @config.always_include.map(&:to_s).reject(&:empty?).uniq
250
- return Hash.new { |h, k| h[k] = [] } if bundles.empty?
251
-
252
- requires_by_bundle = Hash.new { |h, k| h[k] = [] }
253
-
254
- bundle_files.each_key do |bundle_name|
255
- requires = Set.new
256
-
257
- bundles.each do |always_bundle|
258
- next if always_bundle == bundle_name
259
-
260
- transitive = [always_bundle] + transitive_dependencies(always_bundle, dependencies)
261
- transitive.each do |dep_bundle|
262
- bundle_files.fetch(dep_bundle, []).each do |abs_path|
263
- requires << relative_require_path(abs_path)
264
- end
265
- end
266
- end
267
-
268
- requires_by_bundle[bundle_name] = requires.to_a.sort
564
+ bundles.each_with_object(Set.new) do |always_bundle, acc|
565
+ acc << always_bundle
566
+ transitive_dependencies(always_bundle, dependencies).each { |dep| acc << dep }
269
567
  end
270
-
271
- requires_by_bundle
272
568
  end
273
569
 
274
570
  # --------------------------------------------------------------- write
@@ -0,0 +1,75 @@
1
+ require "fileutils"
2
+
3
+ module ReactManifest
4
+ # Ensures the generated manifest dir is gitignored and that a committed .keep
5
+ # keeps the directory present on a fresh clone. Idempotent and best-effort:
6
+ # a filesystem error logs a warning and never raises.
7
+ class GitignorePatcher
8
+ include ReactManifest::Logging
9
+
10
+ Result = Struct.new(:appended, :keep_created, keyword_init: true)
11
+
12
+ def initialize(config = ReactManifest.configuration)
13
+ @config = config
14
+ end
15
+
16
+ def patch!
17
+ Result.new(appended: ensure_gitignore_entry, keep_created: ensure_keep)
18
+ end
19
+
20
+ # patch! plus a one-time hint to untrack previously-committed manifests when the
21
+ # .gitignore entry was just added (the gem never runs git itself). Returns true
22
+ # iff the .gitignore entry was appended.
23
+ def reconcile!
24
+ result = patch!
25
+ if result.appended
26
+ base = File.dirname(pattern)
27
+ log_info "If #{base}/*.js were previously committed, untrack them once: " \
28
+ "git rm --cached #{base}/*.js"
29
+ end
30
+ result.appended
31
+ end
32
+
33
+ # Path (relative to Rails.root) that should be ignored, e.g.
34
+ # "app/assets/javascripts/ux_manifests/*.js".
35
+ def pattern
36
+ subdir = @config.normalized_manifest_subdir
37
+ base = subdir.empty? ? @config.output_dir : File.join(@config.output_dir, subdir)
38
+ File.join(base, "*.js")
39
+ end
40
+
41
+ private
42
+
43
+ def gitignore_path
44
+ Rails.root.join(".gitignore").to_s
45
+ end
46
+
47
+ def ensure_gitignore_entry
48
+ path = gitignore_path
49
+ line = pattern
50
+ content = File.exist?(path) ? File.read(path, encoding: "utf-8") : ""
51
+ return false if content.split("\n").map(&:strip).include?(line)
52
+
53
+ prefix = content.empty? || content.end_with?("\n") ? "" : "\n"
54
+ File.open(path, "a") { |f| f.write("#{prefix}#{line}\n") }
55
+ log_info "Added #{line} to .gitignore"
56
+ true
57
+ rescue Errno::EACCES, Errno::ENOENT => e
58
+ log_warn "Could not update .gitignore: #{e.message}"
59
+ false
60
+ end
61
+
62
+ def ensure_keep
63
+ dir = @config.abs_manifest_dir
64
+ keep = File.join(dir, ".keep")
65
+ return false if File.exist?(keep)
66
+
67
+ FileUtils.mkdir_p(dir)
68
+ FileUtils.touch(keep)
69
+ true
70
+ rescue Errno::EACCES => e
71
+ log_warn "Could not create #{dir}/.keep: #{e.message}"
72
+ false
73
+ end
74
+ end
75
+ end
@@ -26,6 +26,12 @@ module ReactManifest
26
26
  Rails.logger&.warn(error)
27
27
  $stdout.puts(error) if config.stdout_logging?
28
28
  end
29
+
30
+ begin
31
+ ReactManifest.reconcile_gitignore!(config)
32
+ rescue StandardError => e
33
+ Rails.logger&.warn("[ReactManifest] gitignore reconcile failed: #{e.message}")
34
+ end
29
35
  end
30
36
 
31
37
  # Expose config as Rails.application.config.react_manifest
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.34".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
@@ -14,6 +14,7 @@ require "react_manifest/application_analyzer"
14
14
  require "react_manifest/application_migrator"
15
15
  require "react_manifest/layout_patcher"
16
16
  require "react_manifest/sprockets_manifest_patcher"
17
+ require "react_manifest/gitignore_patcher"
17
18
  require "react_manifest/watcher"
18
19
  require "react_manifest/reporter"
19
20
  require "react_manifest/view_helpers"
@@ -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
@@ -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?
@@ -130,6 +146,14 @@ namespace :react_manifest do
130
146
  scan_result = scanner.scan(classification)
131
147
  dep_map = ReactManifest::DependencyMap.new(scan_result)
132
148
  dep_map.print_report
149
+
150
+ reasons = ReactManifest::Generator.new(config).promotion_reasons
151
+ unless reasons.empty?
152
+ puts "\nPromotions (app/ components emitted into #{config.shared_bundle}):"
153
+ reasons.sort.each do |req, bundles|
154
+ puts " #{req} <- used by: #{bundles.sort.join(', ')}"
155
+ end
156
+ end
133
157
  end
134
158
 
135
159
  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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
@@ -155,6 +155,7 @@ files:
155
155
  - lib/react_manifest/configuration.rb
156
156
  - lib/react_manifest/dependency_map.rb
157
157
  - lib/react_manifest/generator.rb
158
+ - lib/react_manifest/gitignore_patcher.rb
158
159
  - lib/react_manifest/layout_patcher.rb
159
160
  - lib/react_manifest/logging.rb
160
161
  - lib/react_manifest/path_utils.rb