react-manifest-rails 0.2.28 → 0.2.29

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.
@@ -1,3 +1,5 @@
1
+ require_relative "path_utils"
2
+
1
3
  module ReactManifest
2
4
  # Scans JS/JSX (and optionally TS/TSX) files using regex — no AST, no Node.js required.
3
5
  #
@@ -16,63 +18,56 @@ module ReactManifest
16
18
  # Phase 3 — emits non-fatal warnings.
17
19
  # rubocop:disable Metrics/ClassLength
18
20
  class Scanner
19
- # Patterns to detect symbol definitions (CommonJS and ES module style)
20
- DEFINITION_PATTERNS = [
21
- # CommonJS / variable-assignment style
22
- /(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/, # const 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
25
- /(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/, # const useFoo = (hooks)
26
- /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/, # function useFoo(
27
-
28
- # ES module style (export default / named exports)
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|\{)/
36
- ].freeze
37
-
38
- # Patterns to detect usage in controller files.
39
- # Token-based patterns match any identifier occurrence regardless of syntax
40
- # context (JSX, constructor, assignment, array, function argument, etc.).
41
- PASCAL_TOKEN_PATTERN = /\b([A-Z][A-Za-z0-9_]*)\b/
42
- HOOK_TOKEN_PATTERN = /\b(use[A-Z][A-Za-z0-9_]*)\b/
43
- # Lib calls matched against known lib symbols to reduce false positives
44
- LIB_CALL_PATTERN = /\b([a-z][A-Za-z0-9_]{2,})\s*\(/
45
-
46
- # Common JS built-ins to exclude from lib-call matching
47
- JS_BUILTINS = %w[
48
- require function return typeof instanceof delete void
49
- console document window location history navigator
50
- setTimeout setInterval clearTimeout clearInterval
51
- parseInt parseFloat isNaN isFinite encodeURI decodeURI
52
- fetch Promise Object Array String Number Boolean Math JSON
53
- Object Array String Number Boolean Symbol Map Set WeakMap
54
- ].freeze
21
+ include PathUtils
22
+ include ReactManifest::Logging
23
+
24
+ DEFINITION_PATTERNS = SymbolExtractor::DEFINITION_PATTERNS
25
+ PASCAL_TOKEN_PATTERN = SymbolExtractor::PASCAL_TOKEN_PATTERN
26
+ HOOK_TOKEN_PATTERN = SymbolExtractor::HOOK_TOKEN_PATTERN
27
+ LIB_CALL_PATTERN = SymbolExtractor::LIB_CALL_PATTERN
28
+ JS_BUILTINS = SymbolExtractor::JS_BUILTINS
55
29
 
56
30
  Result = Struct.new(:symbol_index, :controller_usages, :warnings, :shared_violations,
57
31
  :external_violations, keyword_init: true)
58
32
 
33
+ class << self
34
+ def file_symbol_cache
35
+ @file_symbol_cache ||= {}
36
+ end
37
+
38
+ def clear_cache!
39
+ @file_symbol_cache = {}
40
+ end
41
+
42
+ def invalidate(file_path)
43
+ file_symbol_cache.delete(file_path)
44
+ end
45
+ end
46
+
59
47
  def initialize(config = ReactManifest.configuration)
60
48
  @config = config
61
49
  end
62
50
 
63
- # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
51
+ # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
64
52
  def scan(classification)
65
53
  warnings = Set.new
66
54
  symbol_index = {}
67
55
  external_file_paths = {} # file_path => relative_require_path for external_roots files
68
56
 
69
- # Phase 1a: index symbols from shared dirs
70
- shared_file_paths = {} # file_path => relative_require_path for all shared files
57
+ # Phase 1a: index symbols from shared dirs; cache content for violation detection
58
+ shared_file_paths = {} # file_path => relative_require_path
59
+ shared_file_content = {} # file_path => raw content string
71
60
  classification.shared_dirs.each do |shared_dir|
72
61
  js_files_in(shared_dir[:path]).each do |file_path|
73
62
  relative = relative_require_path(file_path)
74
63
  shared_file_paths[file_path] = relative
75
- symbols = extract_definitions(file_path)
64
+ content = begin
65
+ File.read(file_path, encoding: "utf-8")
66
+ rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
67
+ nil
68
+ end
69
+ shared_file_content[file_path] = content
70
+ symbols = extract_definitions_from(file_path, content)
76
71
  symbols.each do |sym|
77
72
  if symbol_index.key?(sym)
78
73
  warnings.add("Duplicate symbol '#{sym}' in #{relative} (already from #{symbol_index[sym]})")
@@ -101,31 +96,15 @@ module ReactManifest
101
96
  symbol_index[sym] = require_path
102
97
  end
103
98
 
104
- # Phase 1d: build controller (app-dir) symbol index for violation detection
105
- controller_symbol_index = {}
106
- classification.controller_dirs.each do |ctrl|
107
- js_files_in(ctrl[:path]).each do |file_path|
108
- extract_definitions(file_path).each do |sym|
109
- controller_symbol_index[sym] ||= {
110
- file: relative_require_path(file_path),
111
- controller: ctrl[:name]
112
- }
113
- end
114
- end
115
- end
99
+ log_debug "Shared symbol index: #{symbol_index.size} symbols indexed" if @config.verbose?
116
100
 
117
- $stdout.puts "[ReactManifest] Shared symbol index: #{symbol_index.size} symbols indexed" if @config.verbose?
118
-
119
- # Phase 1e: detect shared files that use app-dir (controller) symbols
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
-
123
- # Phase 2: scan controller dirs for usage
101
+ # Phase 2: single pass over controller dirs build violation index AND detect usages
102
+ controller_symbol_index = {}
124
103
  controller_usages = {}
125
104
 
126
105
  classification.controller_dirs.each do |ctrl|
127
- files = js_files_in(ctrl[:path])
128
- used = Set.new
106
+ files = js_files_in(ctrl[:path])
107
+ used = Set.new
129
108
 
130
109
  warnings.add("Controller dir '#{ctrl[:name]}' has no JS/JSX files") if files.empty? && @config.verbose?
131
110
 
@@ -133,13 +112,25 @@ module ReactManifest
133
112
  content = read_controller_file(file_path, warnings)
134
113
  next unless content
135
114
 
115
+ extract_definitions_from(file_path, content).each do |sym|
116
+ controller_symbol_index[sym] ||= {
117
+ file: relative_require_path(file_path),
118
+ controller: ctrl[:name]
119
+ }
120
+ end
121
+
136
122
  used.merge(extract_used_shared_paths(content, symbol_index))
137
123
  end
138
124
 
139
125
  controller_usages[ctrl[:name]] = used.to_a.sort
140
126
  end
141
127
 
142
- # Phase 3: additional warnings
128
+ # Phase 3a: detect shared/external files that use app-dir (controller) symbols
129
+ shared_violations = detect_shared_violations(shared_file_paths, shared_file_content, controller_symbol_index,
130
+ warnings)
131
+ external_violations = detect_external_root_violations(external_file_paths, controller_symbol_index, warnings)
132
+
133
+ # Phase 3b: additional warnings
143
134
  emit_fanout_warnings(controller_usages, warnings)
144
135
 
145
136
  Result.new(
@@ -150,7 +141,7 @@ module ReactManifest
150
141
  external_violations: external_violations
151
142
  )
152
143
  end
153
- # rubocop:enable Metrics/MethodLength,Metrics/AbcSize,Metrics/PerceivedComplexity
144
+ # rubocop:enable Metrics/MethodLength,Metrics/AbcSize
154
145
 
155
146
  private
156
147
 
@@ -159,22 +150,37 @@ module ReactManifest
159
150
 
160
151
  Dir.glob(File.join(dir, "**", @config.extensions_glob))
161
152
  .reject { |f| File.directory?(f) }
162
- .reject { |f| excluded_path?(f) }
153
+ .reject { |f| @config.excluded_path?(f) }
163
154
  .sort
164
155
  end
165
156
 
166
- # Returns true if the file path contains a segment matching any exclude_path.
167
- def excluded_path?(abs_path)
168
- parts = abs_path.split(File::SEPARATOR)
169
- @config.exclude_paths.any? { |ep| parts.include?(ep) }
157
+ def extract_definitions(file_path)
158
+ cache = self.class.file_symbol_cache
159
+ return cache[file_path] if cache.key?(file_path)
160
+
161
+ cache[file_path] = scan_file_definitions(file_path)
170
162
  end
171
163
 
172
- def extract_definitions(file_path)
164
+ # Like extract_definitions but uses pre-read content, populating the cache as a side-effect.
165
+ def extract_definitions_from(file_path, content)
166
+ cache = self.class.file_symbol_cache
167
+ return cache[file_path] if cache.key?(file_path)
168
+
169
+ cache[file_path] = parse_definitions(content)
170
+ end
171
+
172
+ def scan_file_definitions(file_path)
173
173
  begin
174
174
  content = File.read(file_path, encoding: "utf-8")
175
175
  rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
176
176
  return []
177
177
  end
178
+ parse_definitions(content)
179
+ end
180
+
181
+ def parse_definitions(content)
182
+ return [] unless content
183
+
178
184
  symbols = []
179
185
  DEFINITION_PATTERNS.each do |pattern|
180
186
  content.scan(pattern) { |m| symbols << m[0] }
@@ -186,35 +192,24 @@ module ReactManifest
186
192
  # Build relative to output_dir (configurable) rather than a hardcoded path.
187
193
  base = @config.abs_output_dir + File::SEPARATOR
188
194
  rel = abs_path.sub(base, "")
189
- # Strip Sprockets-understood extensions: .js.jsx/.jsx/.js -> logical path.
190
- rel.sub(/\.js\.jsx$/, "").sub(/\.jsx$/, "").sub(/\.js$/, "")
195
+ strip_asset_extension(rel)
191
196
  end
192
197
 
193
- def detect_shared_violations(shared_file_paths, controller_symbol_index, warnings)
198
+ def detect_shared_violations(shared_file_paths, shared_file_content, controller_symbol_index, warnings)
194
199
  violations = []
195
200
  shared_file_paths.each do |file_path, relative|
196
- content = begin
197
- File.read(file_path, encoding: "utf-8")
198
- rescue Errno::ENOENT, Errno::EACCES, Encoding::InvalidByteSequenceError
199
- next
200
- end
201
-
202
- local_syms = Set.new
203
- DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
204
-
205
- [PASCAL_TOKEN_PATTERN, HOOK_TOKEN_PATTERN].each do |pattern|
206
- content.scan(pattern) do |match|
207
- sym = match[0]
208
- next if local_syms.include?(sym)
209
- next unless controller_symbol_index.key?(sym)
210
-
211
- info = controller_symbol_index[sym]
212
- violations << { shared_file: relative, symbol: sym,
213
- controller: info[:controller], app_file: info[:file] }
214
- warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
215
- "(from ux/app/#{info[:controller]}). " \
216
- "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
217
- end
201
+ content = shared_file_content[file_path]
202
+ next unless content
203
+
204
+ SymbolExtractor.extract_usages(content).each do |sym|
205
+ next unless controller_symbol_index.key?(sym)
206
+
207
+ info = controller_symbol_index[sym]
208
+ violations << { shared_file: relative, symbol: sym,
209
+ controller: info[:controller], app_file: info[:file] }
210
+ warnings.add("Shared file '#{relative}' uses app-dir symbol '#{sym}' " \
211
+ "(from ux/app/#{info[:controller]}). " \
212
+ "Move '#{sym}' to a shared dir or the shared file will be incomplete.")
218
213
  end
219
214
  end
220
215
  violations
@@ -229,22 +224,15 @@ module ReactManifest
229
224
  next
230
225
  end
231
226
 
232
- local_syms = Set.new
233
- DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
234
-
235
- [PASCAL_TOKEN_PATTERN, HOOK_TOKEN_PATTERN].each do |pattern|
236
- content.scan(pattern) do |match|
237
- sym = match[0]
238
- next if local_syms.include?(sym)
239
- next unless controller_symbol_index.key?(sym)
240
-
241
- info = controller_symbol_index[sym]
242
- violations << { external_file: relative, symbol: sym,
243
- controller: info[:controller], app_file: info[:file] }
244
- warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
245
- "(from ux/app/#{info[:controller]}). " \
246
- "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
247
- end
227
+ SymbolExtractor.extract_usages(content).each do |sym|
228
+ next unless controller_symbol_index.key?(sym)
229
+
230
+ info = controller_symbol_index[sym]
231
+ violations << { external_file: relative, symbol: sym,
232
+ controller: info[:controller], app_file: info[:file] }
233
+ warnings.add("External file '#{relative}' uses app-dir symbol '#{sym}' " \
234
+ "(from ux/app/#{info[:controller]}). " \
235
+ "Move '#{sym}' into a shared ux dir to avoid duplicate runtime declarations.")
248
236
  end
249
237
  end
250
238
  violations
@@ -314,26 +302,6 @@ module ReactManifest
314
302
  used
315
303
  end
316
304
 
317
- def scan_component_usage(content, pattern, symbol_index, used)
318
- content.scan(pattern) do |match|
319
- sym = match[0]
320
- next unless symbol_index.key?(sym)
321
-
322
- used << symbol_index[sym]
323
- end
324
- end
325
-
326
- def scan_array_component_usage(content, symbol_index, used)
327
- content.scan(ARRAY_COMPONENT_LIST_PATTERN) do |match|
328
- list = match[0]
329
- list.split(/\s*,\s*/).each do |sym|
330
- next unless symbol_index.key?(sym)
331
-
332
- used << symbol_index[sym]
333
- end
334
- end
335
- end
336
-
337
305
  def abs_external_root(path)
338
306
  return path if Pathname.new(path).absolute?
339
307
 
@@ -8,6 +8,8 @@ module ReactManifest
8
8
  # Usage:
9
9
  # ReactManifest::SprocketsManifestPatcher.new(config).patch!
10
10
  class SprocketsManifestPatcher
11
+ include ReactManifest::Logging
12
+
11
13
  MANIFEST_GLOB = "app/assets/config/manifest.js".freeze
12
14
 
13
15
  Result = Struct.new(:file, :status, :detail, keyword_init: true)
@@ -37,13 +39,13 @@ module ReactManifest
37
39
  new_content = append_directive(content, directive)
38
40
 
39
41
  if @config.dry_run?
40
- $stdout.puts "[ReactManifest] DRY-RUN: would patch #{short(path)}"
41
- $stdout.puts " + #{directive.strip}"
42
+ log_info "DRY-RUN: would patch #{short(path)}"
43
+ log_info " + #{directive.strip}"
42
44
  return Result.new(file: path, status: :dry_run, detail: nil)
43
45
  end
44
46
 
45
47
  File.write(path, new_content, encoding: "utf-8")
46
- $stdout.puts "[ReactManifest] Patched Sprockets manifest: #{short(path)}"
48
+ log_info "Patched Sprockets manifest: #{short(path)}"
47
49
  Result.new(file: path, status: :patched, detail: nil)
48
50
  end
49
51
 
@@ -0,0 +1,69 @@
1
+ module ReactManifest
2
+ module SymbolExtractor
3
+ DEFINITION_PATTERNS = [
4
+ /(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
5
+ /function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
6
+ /class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/,
7
+ /(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
8
+ /function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
9
+ /^export\s+default\s+(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/,
10
+ /^export\s+default\s+(?:function|class)\s+(use[A-Z][A-Za-z0-9_]*)/,
11
+ /^export\s+(?:const|let|var)\s+([A-Z][A-Za-z0-9_]*)\s*=/,
12
+ /^export\s+(?:const|let|var)\s+(use[A-Z][A-Za-z0-9_]*)\s*=/,
13
+ /^export\s+function\s+([A-Z][A-Za-z0-9_]*)\s*\(/,
14
+ /^export\s+function\s+(use[A-Z][A-Za-z0-9_]*)\s*\(/,
15
+ /^export\s+class\s+([A-Z][A-Za-z0-9_]*)\s*(?:extends|\{)/
16
+ ].freeze
17
+
18
+ PASCAL_TOKEN_PATTERN = /\b([A-Z][A-Za-z0-9_]*)\b/
19
+ HOOK_TOKEN_PATTERN = /\b(use[A-Z][A-Za-z0-9_]*)\b/
20
+ LIB_CALL_PATTERN = /\b([a-z][A-Za-z0-9_]{2,})\s*\(/
21
+
22
+ JS_BUILTINS = %w[
23
+ require function return typeof instanceof delete void
24
+ console document window location history navigator
25
+ setTimeout setInterval clearTimeout clearInterval
26
+ parseInt parseFloat isNaN isFinite encodeURI decodeURI
27
+ fetch Promise Object Array String Number Boolean Math JSON
28
+ Object Array String Number Boolean Symbol Map Set WeakMap
29
+ ].freeze
30
+
31
+ module_function
32
+
33
+ def extract_definitions(content)
34
+ return [] unless content
35
+
36
+ symbols = []
37
+ DEFINITION_PATTERNS.each do |pattern|
38
+ content.scan(pattern) { |m| symbols << m[0] }
39
+ end
40
+ symbols.uniq
41
+ end
42
+
43
+ def extract_usages(content)
44
+ return [] unless content
45
+
46
+ local_syms = Set.new
47
+ DEFINITION_PATTERNS.each { |p| content.scan(p) { |m| local_syms << m[0] } }
48
+
49
+ used = []
50
+
51
+ content.scan(PASCAL_TOKEN_PATTERN) do |match|
52
+ sym = match[0]
53
+ used << sym unless local_syms.include?(sym)
54
+ end
55
+
56
+ content.scan(HOOK_TOKEN_PATTERN) do |match|
57
+ sym = match[0]
58
+ used << sym unless local_syms.include?(sym)
59
+ end
60
+
61
+ content.scan(LIB_CALL_PATTERN) do |match|
62
+ sym = match[0]
63
+ used << sym unless JS_BUILTINS.include?(sym) || local_syms.include?(sym)
64
+ end
65
+
66
+ used.uniq
67
+ end
68
+ end
69
+ end
@@ -5,6 +5,8 @@ module ReactManifest
5
5
  #
6
6
  # No hard-coded dir names — anything that is not app_dir is shared.
7
7
  class TreeClassifier
8
+ include ReactManifest::Logging
9
+
8
10
  Result = Struct.new(:controller_dirs, :shared_dirs, keyword_init: true)
9
11
 
10
12
  def initialize(config = ReactManifest.configuration)
@@ -16,8 +18,8 @@ module ReactManifest
16
18
  shared_dirs = []
17
19
 
18
20
  unless Dir.exist?(@config.abs_ux_root)
19
- warn "[ReactManifest] ux_root does not exist: #{@config.abs_ux_root}. " \
20
- "Create the directory and run `rails react_manifest:generate`."
21
+ log_warn "ux_root does not exist: #{@config.abs_ux_root}. " \
22
+ "Create the directory and run `rails react_manifest:generate`."
21
23
  return Result.new(controller_dirs: [], shared_dirs: [])
22
24
  end
23
25
 
@@ -40,7 +42,7 @@ module ReactManifest
40
42
  }
41
43
  end
42
44
  rescue Errno::EACCES => e
43
- warn "[ReactManifest] Permission denied reading #{full_path}: #{e.message}"
45
+ log_warn "Permission denied reading #{full_path}: #{e.message}"
44
46
  end
45
47
  else
46
48
  shared_dirs << {
@@ -50,7 +52,7 @@ module ReactManifest
50
52
  end
51
53
  end
52
54
  rescue Errno::EACCES => e
53
- warn "[ReactManifest] Permission denied reading ux_root #{@config.abs_ux_root}: #{e.message}"
55
+ log_warn "Permission denied reading ux_root #{@config.abs_ux_root}: #{e.message}"
54
56
  return Result.new(controller_dirs: [], shared_dirs: [])
55
57
  end
56
58
 
@@ -1,3 +1,3 @@
1
1
  module ReactManifest
2
- VERSION = "0.2.28".freeze
2
+ VERSION = "0.2.29".freeze
3
3
  end
@@ -7,27 +7,36 @@ module ReactManifest
7
7
  #
8
8
  # Watches ux_root recursively so newly added controller directories are
9
9
  # picked up without a server restart.
10
+ #
11
+ # File-change callbacks are debounced by the listen gem and handled on a
12
+ # background thread. Rapid back-to-back changes are coalesced: if a
13
+ # regeneration is already in progress when a new change arrives, only one
14
+ # additional regeneration is queued (not one per file event).
10
15
  module Watcher
11
16
  DEBOUNCE_SECONDS = 0.3
12
17
 
13
18
  class << self
19
+ include ReactManifest::Logging
20
+
14
21
  def start(config = ReactManifest.configuration)
15
22
  begin
16
23
  require "listen"
17
24
  rescue LoadError
18
- log "listen gem not available — file watching disabled. " \
19
- "Add `gem 'listen'` to the development group in your Gemfile."
25
+ log_warn "listen gem not available — file watching disabled. " \
26
+ "Add `gem 'listen'` to the development group in your Gemfile."
20
27
  return
21
28
  end
22
29
 
23
30
  root = config.abs_ux_root
24
31
 
25
32
  unless Dir.exist?(root)
26
- log "ux_root does not exist (#{root}) — file watching disabled until directory is created."
33
+ log_warn "ux_root does not exist (#{root}) — file watching disabled until directory is created."
27
34
  return
28
35
  end
29
36
 
30
- log "Watching #{root.sub("#{Rails.root}/", '')} for changes..."
37
+ @regen_mutex = Mutex.new
38
+
39
+ log_info "Watching #{root.sub("#{Rails.root}/", '')} for changes..."
31
40
 
32
41
  @listener = Listen.to(
33
42
  root,
@@ -35,8 +44,8 @@ module ReactManifest
35
44
  latency: DEBOUNCE_SECONDS
36
45
  ) do |modified, added, removed|
37
46
  changed = (modified + added + removed).map { |f| File.basename(f) }
38
- log "File change detected: #{changed.join(', ')}"
39
- regenerate!(config)
47
+ log_info "File change detected: #{changed.join(', ')}"
48
+ handle_file_changes(modified, added, removed, config)
40
49
  end
41
50
 
42
51
  @listener.start
@@ -45,31 +54,68 @@ module ReactManifest
45
54
  def stop
46
55
  @listener&.stop
47
56
  @listener = nil
57
+ @regen_thread&.join(5)
58
+ @regen_thread = nil
48
59
  end
49
60
 
50
61
  def running?
51
62
  !@listener.nil?
52
63
  end
53
64
 
65
+ # Kill the background regen thread and reset all regen state.
66
+ # Intended for use in tests only.
67
+ def reset_regen_state!
68
+ thread = @regen_thread
69
+ if thread&.alive?
70
+ thread.kill
71
+ thread.join(1)
72
+ end
73
+ @regen_thread = nil
74
+ @regen_pending = false
75
+ @regen_mutex = nil
76
+ end
77
+
54
78
  private
55
79
 
56
- def regenerate!(config)
57
- Generator.new(config).run!
58
- log "Manifests regenerated"
59
- rescue StandardError => e
60
- log "Error during regeneration: #{e.message}"
61
- log e.backtrace.first(5).join("\n") if config.verbose?
80
+ def handle_file_changes(modified, added, removed, config)
81
+ (modified + added + removed).each { |f| Scanner.invalidate(f) }
82
+ schedule_regeneration(config)
62
83
  end
63
84
 
64
- def log(message)
65
- msg = "[ReactManifest] #{message}"
66
- if defined?(Rails) && Rails.logger
67
- Rails.logger.info(msg)
68
- $stdout.puts(msg) if Rails.env.development? && ReactManifest.configuration.stdout_logging?
69
- else
70
- $stdout.puts msg
85
+ # Schedule a regeneration on the background thread. Coalesces rapid
86
+ # back-to-back file events: if the regen thread is already running,
87
+ # we just set @regen_pending and return immediately so the listen
88
+ # callback is never blocked.
89
+ def schedule_regeneration(config)
90
+ @regen_mutex ||= Mutex.new
91
+ mutex = @regen_mutex
92
+ mutex.synchronize do
93
+ @regen_pending = true
94
+ return if @regen_thread&.alive?
95
+
96
+ @regen_thread = Thread.new { regen_loop(config, mutex) }
97
+ end
98
+ end
99
+
100
+ # Background thread: regenerate, then check whether another change
101
+ # arrived while we were busy. If so, regenerate again; otherwise exit.
102
+ def regen_loop(config, mutex)
103
+ loop do
104
+ mutex.synchronize { @regen_pending = false }
105
+ regenerate!(config)
106
+ still_pending = mutex.synchronize { @regen_pending }
107
+ break unless still_pending
71
108
  end
72
109
  end
110
+
111
+ def regenerate!(config)
112
+ results = Generator.new(config).run!
113
+ written = results.count { |r| r[:status] == :written }
114
+ log_info "#{written} manifest(s) written" if written.positive?
115
+ rescue StandardError => e
116
+ log_warn "Error during regeneration: #{e.message}"
117
+ log_debug e.backtrace.first(5).join("\n") if config.verbose?
118
+ end
73
119
  end
74
120
  end
75
121
  end