react_on_rails 16.7.0.rc.2 → 17.0.0.rc.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.
Files changed (26) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.development_dependencies +7 -3
  3. data/Gemfile.lock +14 -6
  4. data/lib/generators/react_on_rails/generator_helper.rb +23 -6
  5. data/lib/generators/react_on_rails/install_generator.rb +5 -3
  6. data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -0
  7. data/lib/generators/react_on_rails/pro_setup.rb +139 -45
  8. data/lib/generators/react_on_rails/rsc_setup/client_references.rb +1362 -0
  9. data/lib/generators/react_on_rails/rsc_setup/layouts.rb +229 -0
  10. data/lib/generators/react_on_rails/rsc_setup.rb +103 -232
  11. data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +11 -1
  12. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +10 -1
  13. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +6 -1
  14. data/lib/generators/react_on_rails/templates/pro/base/renderer/{node-renderer.js → node-renderer.js.tt} +6 -1
  15. data/lib/react_on_rails/dev/server_manager.rb +19 -1
  16. data/lib/react_on_rails/doctor.rb +77 -9
  17. data/lib/react_on_rails/engine.rb +117 -0
  18. data/lib/react_on_rails/helper.rb +35 -1
  19. data/lib/react_on_rails/node_renderer_procfile.rb +43 -0
  20. data/lib/react_on_rails/prerender_error.rb +14 -6
  21. data/lib/react_on_rails/test_helper/webpack_assets_compiler.rb +4 -4
  22. data/lib/react_on_rails/version.rb +1 -1
  23. data/rakelib/lint.rake +1 -1
  24. data/rakelib/task_helpers.rb +0 -4
  25. data/rakelib/update_changelog.rake +61 -6
  26. metadata +6 -3
@@ -0,0 +1,1362 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReactOnRails
4
+ module Generators
5
+ module RscSetup
6
+ module ClientReferences # rubocop:disable Metrics/ModuleLength
7
+ JS_STRING_DELIMITERS = ["'", '"', "`"].freeze
8
+ JS_COMMENT_STATES = %i[line_comment block_comment].freeze
9
+ # Known limitation: this list only covers single-character regex preceders. Multi-character
10
+ # JavaScript keywords that legally precede a regex literal (`return`, `typeof`, `void`,
11
+ # `delete`, `throw`, `case`, `in`, `instanceof`) are not represented. A regex like `/\{/`
12
+ # appearing after `return` may be misidentified as a division operator and let `{` / `}`
13
+ # inside the regex body throw off `matching_js_closing_brace`'s depth counter. The
14
+ # unparseable-section detection in `rsc_plugin_options_followed_by_close_paren?` catches
15
+ # the resulting corruption and flags the section as unparseable, so the migration falls
16
+ # back safely rather than producing a wrong rewrite.
17
+ REGEX_LITERAL_PRECEDERS = ["(", "{", "[", "=", ":", ",", ";", "!", "?", "&", "|", "+", "-", "*", "~", "^",
18
+ "<", ">"].freeze
19
+ # Matches `new RSCWebpackPlugin(` allowing whitespace/newlines between `new`, the class
20
+ # name, and the open paren. Shared by the partition scanner and the routing checks so
21
+ # both detect the same set of invocations.
22
+ RSC_PLUGIN_INVOCATION_REGEX = /new\s+RSCWebpackPlugin\s*\(/
23
+
24
+ private
25
+
26
+ def rsc_client_references_js
27
+ <<~'JS'.chomp
28
+ const rscClientReferences = {
29
+ directory: resolve(config.source_path),
30
+ recursive: true,
31
+ include: /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/,
32
+ };
33
+ JS
34
+ end
35
+
36
+ def inject_rsc_client_imports(config_path, content, existing_imports_content)
37
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: false) do |anchor|
38
+ join_rsc_client_references_setup(
39
+ content,
40
+ [
41
+ anchor,
42
+ shakapacker_config_import_statement(existing_imports_content),
43
+ path_resolve_import_statement(existing_imports_content),
44
+ rsc_webpack_plugin_import_statement(content),
45
+ "",
46
+ rsc_client_references_js
47
+ ]
48
+ )
49
+ end
50
+ end
51
+
52
+ def inject_rsc_server_imports(config_path, content, existing_imports_content)
53
+ # Server from-scratch insertion only reaches this point after
54
+ # `shakapacker_config_blocker_reason` has confirmed `config` is already in scope.
55
+ # Adding `shakapacker_config_import_statement` here would duplicate that binding.
56
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: true) do |anchor|
57
+ join_rsc_client_references_setup(
58
+ content,
59
+ [
60
+ anchor,
61
+ path_resolve_import_statement(existing_imports_content),
62
+ rsc_webpack_plugin_import_statement(content),
63
+ "",
64
+ rsc_client_references_js
65
+ ]
66
+ )
67
+ end
68
+ end
69
+
70
+ def prepare_rsc_plugin_imports(config_path, content, existing_imports_content, is_server:)
71
+ if rsc_setup_blocked_by_later_imports?(
72
+ config_path,
73
+ content,
74
+ existing_imports_content,
75
+ is_server: is_server,
76
+ plugin_pending: true
77
+ )
78
+ return inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server) ? :unscoped : :failed
79
+ end
80
+
81
+ # `rsc_client_references_defined?` covers both scoped and unscoped declarations.
82
+ # Re-emitting `rsc_client_references_js` here would produce a duplicate
83
+ # `const rscClientReferences = {...}` and fail the file with
84
+ # `Identifier 'rscClientReferences' has already been declared`. Route to the
85
+ # plugin-import-only path and let downstream callers honor the scoped/unscoped
86
+ # state of whatever the user already wrote.
87
+ if rsc_client_references_defined?(content)
88
+ return :failed unless inject_rsc_webpack_plugin_import(config_path, content, is_server: is_server)
89
+ return :scoped if scoped_rsc_client_references_defined?(content)
90
+
91
+ warn_unscoped_rsc_client_references_helper(config_path)
92
+ return :unscoped
93
+ end
94
+
95
+ return :failed unless inject_rsc_imports(config_path, content, existing_imports_content, is_server: is_server)
96
+ return :scoped if rsc_client_references_setup_ready?(config_path, plugin_pending: true)
97
+
98
+ :failed
99
+ end
100
+
101
+ def inject_rsc_imports(config_path, content, existing_imports_content, is_server:)
102
+ inserted =
103
+ if is_server
104
+ inject_rsc_server_imports(config_path, content, existing_imports_content)
105
+ else
106
+ inject_rsc_client_imports(config_path, content, existing_imports_content)
107
+ end
108
+ return true if inserted
109
+
110
+ warn_rsc_client_references_injection_failed(config_path, plugin_pending: true)
111
+ false
112
+ end
113
+
114
+ def join_rsc_client_references_setup(content, lines)
115
+ line_ending = js_line_ending(content)
116
+ lines.compact.map { |line| line.gsub(/\r?\n/, line_ending) }.join(line_ending)
117
+ end
118
+
119
+ def js_line_ending(content)
120
+ content.include?("\r\n") ? "\r\n" : "\n"
121
+ end
122
+
123
+ def update_existing_rsc_webpack_config(config_path, content, is_server:)
124
+ return unless rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server: is_server)
125
+ return if rsc_plugin_uses_scoped_client_references?(content, is_server: is_server)
126
+ return unless prepare_rsc_client_references_rewrite!(config_path, content, is_server: is_server)
127
+
128
+ return if rewrite_rsc_plugin_client_references(config_path, is_server: is_server)
129
+
130
+ rollback_incomplete_rsc_client_references_setup(config_path, content)
131
+ warn_missing_rsc_plugin_target(config_path, is_server: is_server)
132
+ end
133
+
134
+ def prepare_rsc_client_references_rewrite!(config_path, content, is_server:)
135
+ # This prepares the rewrite and returns whether it should continue. When it returns true,
136
+ # the scoped helper may already have been injected on disk so
137
+ # `rewrite_rsc_plugin_client_references` can re-read fresh content with valid offsets.
138
+ if rsc_plugin_references_any_scoped_client_references?(content, is_server: is_server)
139
+ return false unless ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
140
+
141
+ return rsc_plugin_needs_client_references_rewrite?(content, is_server: is_server)
142
+ end
143
+
144
+ rewritable_rsc_plugin?(config_path, content, is_server: is_server) &&
145
+ ensure_rsc_client_references_setup(config_path, content, is_server: is_server)
146
+ end
147
+
148
+ def rsc_plugin_needs_client_references_rewrite?(content, is_server:)
149
+ any_rsc_plugin_section_without_client_references?(content, is_server: is_server)
150
+ end
151
+
152
+ # Detects RSCWebpackPlugin option blocks that the lightweight JS scanner could not parse
153
+ # cleanly (most often a regex literal with an unmatched `{` / `}` that walks the depth
154
+ # counter past the real closing brace). When found, we warn and refuse to rewrite anything
155
+ # in the file so a sibling rewrite cannot accidentally splice into a wrong location.
156
+ def rsc_plugin_sections_safe_to_rewrite?(config_path, content, is_server:)
157
+ # `:unparseable` is file-wide: the partitioner increments it before filtering parseable
158
+ # sections by the current `is_server` target.
159
+ unparseable = rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:unparseable)
160
+ return true if unparseable.zero?
161
+
162
+ warn_unparseable_rsc_plugin_sections(config_path, unparseable)
163
+ false
164
+ end
165
+
166
+ def rewritable_rsc_plugin?(config_path, content, is_server:)
167
+ # Mixed same-target plugins are still rewritable: the later rewrite only updates plugins
168
+ # missing clientReferences and leaves sibling custom clientReferences untouched.
169
+ return true if any_rsc_plugin_section_without_client_references?(content, is_server: is_server)
170
+
171
+ if rsc_plugin_defines_client_references?(content, is_server: is_server)
172
+ GeneratorMessages.add_warning(
173
+ "Skipped scoped clientReferences migration for #{config_path} because all matching " \
174
+ "RSCWebpackPlugin instances already define clientReferences (some may already be " \
175
+ "correctly scoped to rscClientReferences). Please verify manually."
176
+ )
177
+ return false
178
+ end
179
+
180
+ warn_missing_rsc_plugin_target(config_path, is_server: is_server)
181
+ false
182
+ end
183
+
184
+ def warn_unparseable_rsc_plugin_sections(config_path, count)
185
+ GeneratorMessages.add_warning(
186
+ "Skipped scoped clientReferences migration for #{config_path}: #{count} RSCWebpackPlugin " \
187
+ "options block(s) contain characters this lightweight scanner cannot parse safely " \
188
+ "(most often a regex literal with an unmatched `{` or `}`, e.g. `/\\{/` or `/[{]/`, " \
189
+ "or a regex literal after a keyword context such as `return` or `typeof`). " \
190
+ "All RSCWebpackPlugin calls in the file must be parseable for auto-migration to proceed, " \
191
+ "including calls targeting the other bundle. " \
192
+ "Please add `clientReferences: rscClientReferences` manually to any RSCWebpackPlugin " \
193
+ "that is missing it."
194
+ )
195
+ end
196
+
197
+ def ensure_rsc_client_references_setup(config_path, content, is_server:)
198
+ return true if scoped_rsc_client_references_defined?(content)
199
+
200
+ if rsc_client_references_defined?(content)
201
+ warn_unscoped_rsc_client_references_helper(config_path)
202
+ return false
203
+ end
204
+
205
+ unless rsc_client_references_setup_anchor?(content, is_server: is_server)
206
+ warn_missing_rsc_client_references_anchor(config_path)
207
+ return false
208
+ end
209
+
210
+ existing_imports_content = content_through_rsc_setup_anchor(content, is_server: is_server)
211
+ return false if rsc_setup_blocked_by_later_imports?(config_path, content, existing_imports_content,
212
+ is_server: is_server)
213
+
214
+ return false unless add_rsc_client_references_setup(config_path, content, existing_imports_content,
215
+ is_server: is_server)
216
+ return true if options[:skip]
217
+
218
+ rsc_client_references_setup_ready?(config_path)
219
+ end
220
+
221
+ def rsc_plugin_uses_scoped_client_references?(content, is_server:)
222
+ scoped_rsc_client_references_defined?(content) &&
223
+ rsc_plugin_references_scoped_client_references?(content, is_server: is_server)
224
+ end
225
+
226
+ def rsc_plugin_references_scoped_client_references?(content, is_server:)
227
+ sections = rsc_plugin_option_sections(content, is_server: is_server)
228
+ return false if sections.empty?
229
+
230
+ sections.all? do |section|
231
+ rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
232
+ end
233
+ end
234
+
235
+ def rsc_plugin_references_any_scoped_client_references?(content, is_server:)
236
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
237
+ rsc_plugin_body_has_top_level_scoped_client_references?(section.fetch(:body))
238
+ end
239
+ end
240
+
241
+ def rsc_plugin_client_references_configured?(content, is_server:)
242
+ sections = rsc_plugin_option_sections(content, is_server: is_server)
243
+ # No parseable `isServer: <bool>` section means this file's plugin call sits outside
244
+ # what the generator's scanner can match (e.g. options are computed at runtime, or the
245
+ # plugin is invoked without an options object). Verification callers intentionally
246
+ # under-report here: warning about "missing scoped clientReferences" when there's no
247
+ # section to inspect would only surface noise for dynamic invocations like
248
+ # `RSCWebpackPlugin(buildOptions())`, where the user has nothing actionable to do.
249
+ return true if sections.empty?
250
+
251
+ sections.all? do |section|
252
+ body = section.fetch(:body)
253
+ if rsc_plugin_body_has_top_level_scoped_client_references?(body)
254
+ scoped_rsc_client_references_defined?(content)
255
+ else
256
+ rsc_plugin_body_has_top_level_key?(body, "clientReferences")
257
+ end
258
+ end
259
+ end
260
+
261
+ def rsc_plugin_defines_client_references?(content, is_server:)
262
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
263
+ rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
264
+ end
265
+ end
266
+
267
+ # Existential check: returns true when at least one matching plugin section is missing a
268
+ # top-level `clientReferences:` key. Pairs with `rsc_plugin_defines_client_references?`,
269
+ # which uses the same any-section semantics for the opposite condition. The two are not
270
+ # complements when multiple plugin sections exist — a file with one configured plugin and
271
+ # one unconfigured plugin returns true from both.
272
+ def any_rsc_plugin_section_without_client_references?(content, is_server:)
273
+ rsc_plugin_option_sections(content, is_server: is_server).any? do |section|
274
+ !rsc_plugin_body_has_top_level_key?(section.fetch(:body), "clientReferences")
275
+ end
276
+ end
277
+
278
+ # Strips JavaScript line and block comments while preserving string-literal contents,
279
+ # including simple `${...}` template-literal interpolation, so `clientReferences:` /
280
+ # `isServer:` substrings inside strings are not mis-detected.
281
+ # Shares the `advance_js_scan_state` family used by `js_top_level_position?` and
282
+ # `matching_js_closing_brace` so all JS-aware passes follow the same comment/string rules.
283
+ # See `advance_js_scan_state` for the scanner's supported surface (including the regex-
284
+ # literal and nested-template-literal limits that callers must be aware of).
285
+ def rsc_plugin_options_without_comments(options)
286
+ result = String.new(capacity: options.length)
287
+ state = nil
288
+ escaped = false
289
+ index = 0
290
+
291
+ while index < options.length
292
+ char = options[index]
293
+ next_char = options[index + 1]
294
+ previous_state = state
295
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
296
+
297
+ # Emit when (a) we're outside comments and strings and not just entering one, or
298
+ # (b) we're inside or exiting a string (preserves the opening/closing quote and the
299
+ # string contents), or (c) we're closing a line comment so the trailing `\n` survives
300
+ # for line-anchored regex matching downstream (e.g. `^\s*isServer`).
301
+ emit_char = (previous_state.nil? && !JS_COMMENT_STATES.include?(state)) ||
302
+ JS_STRING_DELIMITERS.include?(previous_state) ||
303
+ (previous_state == :line_comment && char == "\n")
304
+ result << char if emit_char
305
+
306
+ index += 1
307
+ end
308
+
309
+ result
310
+ end
311
+
312
+ # NOTE: `js_code_position?` itself walks from index 0 on each call, so the partition runs
313
+ # in O(n³ × m) (content-length³ × plugin-count) — `js_regex_literal_start?` calls
314
+ # `last_js_code_char_index` on each character, making `js_code_position?` itself O(n²),
315
+ # and every plugin hit triggers a fresh rescan. Acceptable for small webpack configs
316
+ # (a few hundred lines, a handful of plugins); if this scanner is ever reused on larger
317
+ # inputs, carry scanner state forward between iterations before adopting it.
318
+ def rsc_plugin_option_sections(content, is_server:)
319
+ rsc_plugin_option_sections_partition(content, is_server: is_server).fetch(:safe)
320
+ end
321
+
322
+ # Returns the matching plugin sections plus a count of `RSCWebpackPlugin(` invocations
323
+ # whose options block could not be parsed cleanly. An invocation is treated as
324
+ # unparseable when the depth scanner cannot find a matching `}` (over-count caused by an
325
+ # unmatched `{` in a regex literal) or when the `}` it finds is not followed by the `)`
326
+ # that would close the `new RSCWebpackPlugin(...)` call (under-count caused by an
327
+ # unmatched `}` in a regex literal). Both cases mean a rewrite based on this section
328
+ # would corrupt the file, so callers must surface a warning instead of silently skipping.
329
+ def rsc_plugin_option_sections_partition(content, is_server:)
330
+ safe = []
331
+ unparseable = 0
332
+ search_from = 0
333
+
334
+ while (match = content.match(RSC_PLUGIN_INVOCATION_REGEX, search_from))
335
+ call_start = match.begin(0)
336
+ after_open_paren = match.end(0)
337
+ unless js_code_position?(content, call_start)
338
+ search_from = after_open_paren
339
+ next
340
+ end
341
+
342
+ options_start = first_significant_js_index(content, after_open_paren)
343
+ unless options_start && content[options_start] == "{"
344
+ search_from = after_open_paren
345
+ next
346
+ end
347
+
348
+ options_end = matching_js_closing_brace(content, options_start)
349
+ unless options_end
350
+ unparseable += 1
351
+ search_from = options_start + 1
352
+ next
353
+ end
354
+
355
+ unless rsc_plugin_options_followed_by_close_paren?(content, options_end)
356
+ unparseable += 1
357
+ search_from = options_end + 1
358
+ next
359
+ end
360
+
361
+ body = content[(options_start + 1)...options_end]
362
+ if rsc_plugin_is_server_match?(body, is_server: is_server)
363
+ safe << { body: body, body_start: options_start + 1, body_end: options_end }
364
+ end
365
+ search_from = options_end + 1
366
+ end
367
+
368
+ { safe: safe, unparseable: unparseable }
369
+ end
370
+
371
+ # Walks forward from the assumed closing `}` of an options object, skipping whitespace
372
+ # and JS comments, and confirms the next significant character is `)`. Used to detect
373
+ # when `matching_js_closing_brace` was confused by a regex literal and returned an
374
+ # earlier `}` than the real options-object close.
375
+ #
376
+ # String literals between `}` and `)` are not handled because no valid JS places one
377
+ # there in `new RSCWebpackPlugin({...})` — a leading string-delimiter character would
378
+ # simply be returned as a non-`)` and the section would be marked unparseable, which is
379
+ # the safe outcome.
380
+ def rsc_plugin_options_followed_by_close_paren?(content, options_end)
381
+ state = nil
382
+ escaped = false
383
+ index = options_end + 1
384
+ # Tolerate one trailing comma between the options object and `)` so configs formatted
385
+ # with Prettier's `trailingComma: "all"` (`new RSCWebpackPlugin({...},)`) aren't flagged
386
+ # as unparseable. A second comma still bails — that would be invalid JS.
387
+ comma_seen = false
388
+
389
+ while index < content.length
390
+ char = content[index]
391
+ next_char = content[index + 1]
392
+ prev_state = state
393
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
394
+ # Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
395
+ # `/`; advance past it so the next iteration evaluates the first character after `*/`.
396
+ if state || prev_state == :block_comment
397
+ index += 1
398
+ next
399
+ end
400
+
401
+ unless char.match?(/\s/)
402
+ return true if char == ")"
403
+ return false if comma_seen || char != ","
404
+
405
+ comma_seen = true
406
+ end
407
+
408
+ index += 1
409
+ end
410
+
411
+ false
412
+ end
413
+
414
+ # Skips whitespace, JS line/block comments, and leading string literals so callers see the
415
+ # next structural character. Without comment skipping, configurations like
416
+ # `new RSCWebpackPlugin( /* opts */ {` would land on `/` and be rejected as "no plugin
417
+ # options" even though the options object is present.
418
+ def first_significant_js_index(content, start_index)
419
+ index = start_index
420
+ state = nil
421
+ escaped = false
422
+
423
+ while index < content.length
424
+ char = content[index]
425
+ next_char = content[index + 1]
426
+ prev_state = state
427
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
428
+ # Exiting a block comment leaves `char` as `*` and `index` pointing at the closing
429
+ # `/`; advance past it so the next iteration evaluates the first character after `*/`.
430
+ if state || prev_state == :block_comment
431
+ index += 1
432
+ next
433
+ end
434
+
435
+ return index unless char.match?(/\s/) || JS_STRING_DELIMITERS.include?(prev_state)
436
+
437
+ index += 1
438
+ end
439
+
440
+ nil
441
+ end
442
+
443
+ # Expects `content[open_index] == "{"`; callers pass the options-object opening brace.
444
+ # This lightweight scanner supports strings (including template literals for simple
445
+ # `${...}` interpolation) plus JS line/block comments. It does not classify regex
446
+ # literals, so braces inside constructs such as `/a{2}/` or `/[{]/` can be counted as
447
+ # object braces. Nested template literals (for example, `outer ${`inner`}`) are also
448
+ # unsupported: the inner backtick falsely closes the outer string state, exposing later
449
+ # braces to the depth counter. `rsc_plugin_options_without_comments` shares the same
450
+ # supported surface, and callers detect corrupted sections via
451
+ # `rsc_plugin_options_followed_by_close_paren?` so the migration warns instead of
452
+ # producing a corrupt rewrite.
453
+ def matching_js_closing_brace(content, open_index)
454
+ depth = 0
455
+ index = open_index
456
+ state = nil
457
+ escaped = false
458
+
459
+ while index < content.length
460
+ char = content[index]
461
+ # Nil at EOF is safe because downstream comparisons treat it as a non-match.
462
+ next_char = content[index + 1]
463
+
464
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
465
+ if state
466
+ index += 1
467
+ next
468
+ end
469
+
470
+ # `block_comment_exit_guard`: relies on `*` matching neither `{` nor `}`, so the
471
+ # `*` returned at a `*/` exit is harmless here. If any future check below adds a
472
+ # branch that could fire on `*` (or `)`, `/`, etc.), guard it with
473
+ # `prev_state == :block_comment` per the contract on `advance_js_scan_state`.
474
+ depth += 1 if char == "{"
475
+ if char == "}"
476
+ depth -= 1
477
+ return index if depth.zero?
478
+ end
479
+ index += 1
480
+ end
481
+
482
+ nil
483
+ end
484
+
485
+ # Central dispatcher for the lightweight JS scanner shared by every JS-aware pass in this
486
+ # generator (`matching_js_closing_brace`, `js_top_level_position?`, `js_code_position?`,
487
+ # `rsc_plugin_options_without_comments`, `first_significant_js_index`,
488
+ # `rsc_plugin_options_followed_by_close_paren?`, `last_js_code_char_index`,
489
+ # `last_js_code_line_start`). Return index is the last consumed character. Line comments
490
+ # leave the newline for the caller's normal index increment; block comments consume the
491
+ # closing slash.
492
+ #
493
+ # Supported lexical constructs:
494
+ # - Line comments (`// ...\n`) and block comments (`/* ... */`).
495
+ # - Single-quoted (`'...'`), double-quoted (`"..."`), and template-literal (`` `...` ``)
496
+ # strings, including escape sequences and the simple `${expr}` interpolation form
497
+ # (interpolation braces stay inside the string state and never reach the depth counter).
498
+ #
499
+ # Outside the supported surface for callers that do **not** run `js_regex_literal_start?`
500
+ # preprocessing (e.g. `matching_js_closing_brace`, `rsc_plugin_options_without_comments`),
501
+ # the scanner cannot distinguish these from the syntax they shadow, so `{`/`}` characters
502
+ # they contain can confuse the depth counter. `js_top_level_position?` and
503
+ # `js_code_position?` handle regex literals via their own pre-pass and are unaffected.
504
+ # - Regex literals (e.g. `/a{2}/`, `/\{/`, `/[{]/`): not recognized as a distinct state,
505
+ # so brace-containing patterns walk the depth counter past the real options close. The
506
+ # user-facing warning text in `warn_unparseable_rsc_plugin_sections` calls these out
507
+ # explicitly.
508
+ # - Nested template literals (`` `outer ${`inner`}` ``): the inner backtick falsely closes
509
+ # the outer string state, exposing later braces to the depth counter.
510
+ #
511
+ # The downstream `rsc_plugin_option_sections_partition` catches both failure modes by
512
+ # requiring the matched closing `}` to be followed by `)`. When it isn't, the section is
513
+ # marked unparseable and `warn_unparseable_rsc_plugin_sections` asks the user to add
514
+ # `clientReferences:` manually — the migration declines to rewrite rather than risk
515
+ # corrupting the config.
516
+ #
517
+ # Future expansion (only worth doing if a real-world RSC plugin options block needs it):
518
+ # 1. Add a `:regex_literal` state alongside the string and comment states. Track regex
519
+ # contexts by detecting `/` after a token that legally precedes a regex literal
520
+ # (`=`, `(`, `,`, `:`, `;`, `?`, `!`, `&&`, `||`, `return`, `typeof`, etc.) and consume
521
+ # until the unescaped closing `/` plus any flags. The token-context check is necessary
522
+ # because the same `/` character means division in expression position.
523
+ # 2. Add a stack-based template-literal state so nested `` `...${`inner`}...` `` pairs
524
+ # track depth instead of toggling a single boolean state.
525
+ # Regex literals require expanding `advance_js_default_scan_state`; nested template
526
+ # literals would also require replacing `advance_js_string_state` with stack-aware
527
+ # handling. Both changes need a new state-machine branch; the current callers were
528
+ # specifically designed around the simpler scanner and would need re-validation against
529
+ # the expanded state set.
530
+ #
531
+ # IMPORTANT CALLER CONTRACT — block-comment exit:
532
+ # When this returns from a `*/` exit, `state` is cleared, but `char` is still the `*` and
533
+ # the returned `index` points at the closing `/` (so the caller's trailing `index += 1`
534
+ # lands on the first char after `*/`). Any caller whose post-call branch inspects `char`
535
+ # as a "significant character" (e.g. `==` checks against `*`, `/`, `)`, `{`, `}`) MUST
536
+ # explicitly guard on `prev_state == :block_comment` before that branch — otherwise the
537
+ # `*` from the comment terminator is misread as code. Callers that only update accumulator
538
+ # state (depth counters, string/comment booleans) are inherently safe because `*` doesn't
539
+ # affect those. Searchable invariant name: `block_comment_exit_guard`.
540
+ def advance_js_scan_state(state, escaped, char, next_char, index)
541
+ return [char == "\n" ? nil : :line_comment, escaped, index] if state == :line_comment
542
+ return advance_js_block_comment_state(escaped, char, next_char, index) if state == :block_comment
543
+ return advance_js_string_state(state, escaped, char, index) if JS_STRING_DELIMITERS.include?(state)
544
+
545
+ advance_js_default_scan_state(escaped, char, next_char, index)
546
+ end
547
+
548
+ def advance_js_block_comment_state(escaped, char, next_char, index)
549
+ return [nil, escaped, index + 1] if char == "*" && next_char == "/"
550
+
551
+ [:block_comment, escaped, index]
552
+ end
553
+
554
+ def advance_js_string_state(state, escaped, char, index)
555
+ return [state, false, index] if escaped
556
+ return [state, true, index] if char == "\\"
557
+ # Explicit `false` (rather than passing `escaped` through) so a caller starting mid-parse
558
+ # in a string state with a stale `escaped = true` cannot silently suppress the closing
559
+ # quote and leave the scanner stuck in string state.
560
+ return [nil, false, index] if char == state
561
+
562
+ [state, escaped, index]
563
+ end
564
+
565
+ def advance_js_default_scan_state(escaped, char, next_char, index)
566
+ return [:line_comment, escaped, index + 1] if char == "/" && next_char == "/"
567
+ return [:block_comment, escaped, index + 1] if char == "/" && next_char == "*"
568
+ return [char, escaped, index] if JS_STRING_DELIMITERS.include?(char)
569
+
570
+ [nil, escaped, index]
571
+ end
572
+
573
+ def js_code_position?(content, target_index)
574
+ state = nil
575
+ escaped = false
576
+ index = 0
577
+
578
+ while index < target_index
579
+ char = content[index]
580
+ next_char = content[index + 1]
581
+ if state.nil? && js_regex_literal_start?(content, index)
582
+ regex_end = js_regex_literal_end(content, index)
583
+ return false if regex_end >= target_index
584
+
585
+ index = regex_end + 1
586
+ next
587
+ end
588
+
589
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
590
+ index += 1
591
+ end
592
+
593
+ state.nil?
594
+ end
595
+
596
+ def js_top_level_position?(content, target_index)
597
+ state = nil
598
+ escaped = false
599
+ depth = 0
600
+ index = 0
601
+
602
+ while index < target_index
603
+ char = content[index]
604
+ next_char = content[index + 1]
605
+ if state.nil? && js_regex_literal_start?(content, index)
606
+ regex_end = js_regex_literal_end(content, index)
607
+ return false if regex_end >= target_index
608
+
609
+ index = regex_end + 1
610
+ next
611
+ end
612
+
613
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
614
+ if state
615
+ index += 1
616
+ next
617
+ end
618
+
619
+ depth += js_depth_delta(char, depth)
620
+ index += 1
621
+ end
622
+
623
+ state.nil? && depth.zero?
624
+ end
625
+
626
+ def js_depth_delta(char, depth)
627
+ return 1 if char == "{"
628
+ return -1 if char == "}" && depth.positive?
629
+
630
+ 0
631
+ end
632
+
633
+ def js_regex_literal_start?(content, index)
634
+ return false unless content[index] == "/"
635
+ return false if ["/", "*"].include?(content[index + 1])
636
+
637
+ previous = previous_significant_js_char(content, index)
638
+ previous.nil? || REGEX_LITERAL_PRECEDERS.include?(previous)
639
+ end
640
+
641
+ def previous_significant_js_char(content, index)
642
+ prefix = content[0...index]
643
+ previous_index = last_js_code_char_index(prefix)
644
+ previous_index ? prefix[previous_index] : nil
645
+ end
646
+
647
+ def js_regex_literal_end(content, start_index)
648
+ index = start_index + 1
649
+ escaped = false
650
+ in_character_class = false
651
+
652
+ while index < content.length
653
+ char = content[index]
654
+ return js_regex_literal_flag_end(content, index) if js_regex_literal_closing_slash?(
655
+ char, escaped, in_character_class
656
+ )
657
+
658
+ escaped, in_character_class = next_js_regex_literal_state(char, escaped, in_character_class)
659
+ index += 1
660
+ end
661
+
662
+ # Unterminated regex literals leave the rest of the file in invalid JS, so treat the
663
+ # remaining content as non-code instead of resuming the normal brace/import scanner.
664
+ content.length - 1
665
+ end
666
+
667
+ def js_regex_literal_closing_slash?(char, escaped, in_character_class)
668
+ char == "/" && !escaped && !in_character_class
669
+ end
670
+
671
+ def js_regex_literal_flag_end(content, slash_index)
672
+ index = slash_index + 1
673
+ index += 1 while content[index]&.match?(/[a-z]/i)
674
+ index - 1
675
+ end
676
+
677
+ def next_js_regex_literal_state(char, escaped, in_character_class)
678
+ return [false, in_character_class] if escaped
679
+ return [true, in_character_class] if char == "\\"
680
+ return [false, true] if char == "["
681
+ return [false, false] if char == "]"
682
+
683
+ [false, in_character_class]
684
+ end
685
+
686
+ # Depth-aware: a nested `metadata: { isServer: true }` inside another option's value
687
+ # must NOT route the section into the `is_server: true` partition bucket — otherwise
688
+ # the rewrite would correctly bail (the splice helper is also depth-aware) but
689
+ # `warn_missing_rsc_plugin_target` would still fire and the user sees a misleading
690
+ # "no plugin options with isServer: true could be rewritten" warning for a config that
691
+ # is actually fine. Mirrors `rsc_plugin_body_has_top_level_key?` so partitioning and
692
+ # splicing agree on what counts as a top-level `isServer:` pair. Comment/string skipping
693
+ # is handled by `js_top_level_position?` via the shared `advance_js_scan_state`.
694
+ def rsc_plugin_is_server_match?(body, is_server:)
695
+ pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
696
+ search_from = 0
697
+ while (match = pattern.match(body, search_from))
698
+ return true if js_top_level_position?(body, match.begin(0))
699
+
700
+ search_from = match.end(0)
701
+ end
702
+ false
703
+ end
704
+
705
+ def rewrite_rsc_plugin_client_references(config_path, is_server:)
706
+ full_path = File.join(destination_root, config_path)
707
+ # Re-read because ensure_rsc_client_references_setup may have just inserted the helper,
708
+ # making the caller's in-memory body_start/body_end offsets stale.
709
+ content = File.read(full_path)
710
+ rewrites = rsc_plugin_option_sections(content, is_server: is_server).filter_map do |candidate|
711
+ body = candidate.fetch(:body)
712
+ # Depth-aware check: a nested `clientReferences:` (e.g. inside a sibling object
713
+ # literal) must not be mistaken for a configured top-level option, or we'd skip
714
+ # the migration and leave the real plugin unscoped.
715
+ next if rsc_plugin_body_has_top_level_key?(body, "clientReferences")
716
+
717
+ rewritten_body = add_client_references_to_rsc_plugin_body(body, is_server: is_server)
718
+ next if rewritten_body == body
719
+
720
+ [candidate, rewritten_body]
721
+ end
722
+
723
+ # The sole caller (`update_existing_rsc_webpack_config`) translates this `false` into
724
+ # a `warn_missing_rsc_plugin_target` warning, so silently returning here is intentional.
725
+ return false if rewrites.empty?
726
+
727
+ # Reverse order so earlier offsets stay valid as later sections are spliced.
728
+ rewrites.reverse_each do |section, rewritten_body|
729
+ content[section.fetch(:body_start)...section.fetch(:body_end)] = rewritten_body
730
+ end
731
+ write_existing_rsc_config(config_path, content, action: :rewrite)
732
+ true
733
+ end
734
+
735
+ def rollback_incomplete_rsc_client_references_setup(config_path, original_content)
736
+ return if options[:pretend] || options[:skip]
737
+ return if scoped_rsc_client_references_defined?(original_content)
738
+
739
+ full_path = File.join(destination_root, config_path)
740
+ return unless scoped_rsc_client_references_defined?(File.read(full_path))
741
+
742
+ say_status(:revert, config_path, :yellow)
743
+ File.write(full_path, original_content)
744
+ end
745
+
746
+ # Multi-line option objects get the new key on its own line just before the closing brace,
747
+ # with indentation matching the last existing key, so the result reads cleanly without a
748
+ # formatter pass. Single-line option objects keep the same-line splice immediately after
749
+ # `isServer:` because that's the only readable place for a one-line object literal.
750
+ def add_client_references_to_rsc_plugin_body(body, is_server:)
751
+ pattern = /\bisServer\s*:\s*#{Regexp.escape(is_server.to_s)}\b/
752
+ search_from = 0
753
+
754
+ while (matched_is_server = pattern.match(body, search_from))
755
+ # Require depth zero so a nested `isServer:` inside a sibling object literal
756
+ # doesn't cause the splice to land inside the wrong object — `body` is the
757
+ # content between the plugin options braces, so depth 0 == top-level options.
758
+ if js_top_level_position?(body, matched_is_server.begin(0))
759
+ return splice_client_references_into_rsc_plugin_body(body, matched_is_server)
760
+ end
761
+
762
+ search_from = matched_is_server.end(0)
763
+ end
764
+
765
+ body
766
+ end
767
+
768
+ def inject_rsc_webpack_plugin_import(config_path, content, is_server:)
769
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
770
+ join_rsc_client_references_setup(
771
+ content,
772
+ [
773
+ anchor,
774
+ rsc_webpack_plugin_import_statement(content)
775
+ ]
776
+ )
777
+ end
778
+ end
779
+
780
+ # Walks every `<key>:` / shorthand `<key>` / quoted `"<key>":` match in the plugin options
781
+ # body and returns true when at least one sits at the top level of the options object
782
+ # (depth 0 from the body's perspective, outside strings and comments). Used to gate
783
+ # "already configured" checks so nested mentions and value references don't cause false
784
+ # positives.
785
+ def rsc_plugin_body_has_top_level_key?(body, key)
786
+ rsc_plugin_body_has_top_level_bare_key?(body, key) ||
787
+ rsc_plugin_body_has_top_level_quoted_key?(body, key)
788
+ end
789
+
790
+ def rsc_plugin_body_has_top_level_bare_key?(body, key)
791
+ pattern = /\b#{Regexp.escape(key)}\b/
792
+ search_from = 0
793
+
794
+ while (match = pattern.match(body, search_from))
795
+ return true if js_top_level_position?(body, match.begin(0)) &&
796
+ rsc_plugin_body_key_position?(body, match.begin(0), match.end(0))
797
+
798
+ search_from = match.end(0)
799
+ end
800
+
801
+ false
802
+ end
803
+
804
+ def rsc_plugin_body_has_top_level_quoted_key?(body, key)
805
+ pattern = /(['"`])#{Regexp.escape(key)}\1/
806
+ search_from = 0
807
+
808
+ while (match = pattern.match(body, search_from))
809
+ return true if js_top_level_position?(body, match.begin(0)) &&
810
+ rsc_plugin_body_key_position?(body, match.begin(0), match.end(0), quoted: true)
811
+
812
+ search_from = match.end(0)
813
+ end
814
+
815
+ false
816
+ end
817
+
818
+ def rsc_plugin_body_key_position?(body, key_start, key_end, quoted: false)
819
+ previous_char = previous_significant_js_char(body, key_start)
820
+ return false unless previous_char.nil? || previous_char == ","
821
+
822
+ next_index = first_significant_js_index(body, key_end)
823
+ next_char = next_index ? body[next_index] : nil
824
+ return next_char == ":" if quoted
825
+
826
+ next_char.nil? || next_char == ":" || next_char == ","
827
+ end
828
+
829
+ # Top-level depth-aware match for the migrated `clientReferences: rscClientReferences`
830
+ # pair. Mirrors `rsc_plugin_body_has_top_level_key?` so verification and gating share
831
+ # the same comment-, string-, and brace-aware semantics as the rewrite path.
832
+ def rsc_plugin_body_has_top_level_scoped_client_references?(body)
833
+ [
834
+ /\bclientReferences\s*:\s*rscClientReferences\b/,
835
+ /(['"`])clientReferences\1\s*:\s*rscClientReferences\b/
836
+ ].any? do |pattern|
837
+ search_from = 0
838
+
839
+ while (match = pattern.match(body, search_from))
840
+ return true if js_top_level_position?(body, match.begin(0))
841
+
842
+ search_from = match.end(0)
843
+ end
844
+ end
845
+
846
+ false
847
+ end
848
+
849
+ def splice_client_references_into_rsc_plugin_body(body, is_server_match)
850
+ return splice_client_references_at_close_brace(body) if body.include?("\n")
851
+ # Other options follow `isServer:` on the same line — append after the last option so
852
+ # `clientReferences` lands at the end of the object, matching the multi-line path's
853
+ # close-brace splice rather than landing mid-object.
854
+ return splice_client_references_at_single_line_end(body) if trailing_options_after?(body, is_server_match)
855
+
856
+ "#{body[0...is_server_match.end(0)]}, clientReferences: rscClientReferences" \
857
+ "#{body[is_server_match.end(0)..]}"
858
+ end
859
+
860
+ def trailing_options_after?(body, is_server_match)
861
+ rest = body[is_server_match.end(0)..] || ""
862
+ # A bare trailing comma (`isServer: false,`) is structural, not another option, so
863
+ # consider only non-whitespace beyond it as "trailing options".
864
+ rest.sub(/\A\s*,/, "").match?(/\S/)
865
+ end
866
+
867
+ def splice_client_references_at_single_line_end(body)
868
+ trailing = body[/\s*\z/]
869
+ content = body[0...(body.length - trailing.length)]
870
+ content_without_comments = rsc_plugin_options_without_comments(content).rstrip
871
+ separator = content_without_comments.end_with?(",") ? " " : ", "
872
+ "#{content}#{separator}clientReferences: rscClientReferences#{trailing}"
873
+ end
874
+
875
+ def splice_client_references_at_close_brace(body)
876
+ trailing = body[/\s*\z/]
877
+ content = body[0...(body.length - trailing.length)]
878
+ line_ending = js_line_ending(body)
879
+
880
+ last_code_index = last_js_code_char_index(content)
881
+ last_line_start = last_code_index ? last_js_code_line_start(content, last_code_index) : 0
882
+ # `[ \t]+` (one-or-more) so the regex returns nil when the last line is unindented,
883
+ # letting the `|| " "` fallback actually fire. With `*` the regex would always match
884
+ # the empty string and the fallback was unreachable dead code.
885
+ indent = content[last_line_start..][/\A[ \t]+/] || " "
886
+
887
+ content_without_comments = rsc_plugin_options_without_comments(content).rstrip
888
+ needs_comma = !content_without_comments.end_with?(",")
889
+ prefix = build_splice_prefix(content, needs_comma: needs_comma)
890
+
891
+ "#{prefix}#{line_ending}#{indent}clientReferences: rscClientReferences,#{trailing}"
892
+ end
893
+
894
+ def last_js_code_line_start(content, last_code_index)
895
+ state = nil
896
+ escaped = false
897
+ index = 0
898
+ line_start = 0
899
+
900
+ while index <= last_code_index
901
+ char = content[index]
902
+ next_char = content[index + 1]
903
+ prev_state = state
904
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
905
+ line_start = index + 1 if char == "\n" && !JS_STRING_DELIMITERS.include?(prev_state)
906
+
907
+ index += 1
908
+ end
909
+
910
+ line_start
911
+ end
912
+
913
+ # Inserts the trailing comma before any final line/block comment so the rewritten file reads
914
+ # cleanly to a human. Appending the comma to the raw `content` would tuck it inside a
915
+ # trailing `// note` (yielding `isServer: false // note,`) — syntactically valid because
916
+ # the comment hides the comma from the parser, but visually broken in code review and
917
+ # likely to confuse a linter.
918
+ def build_splice_prefix(content, needs_comma:)
919
+ return content unless needs_comma
920
+
921
+ last_code_index = last_js_code_char_index(content)
922
+ return "#{content}," unless last_code_index
923
+
924
+ "#{content[0..last_code_index]},#{content[(last_code_index + 1)..]}"
925
+ end
926
+
927
+ # Returns the index of the last character in `content` that is part of executable code —
928
+ # i.e. neither whitespace nor inside a JS line/block comment. Strings count as code so a
929
+ # value ending in `"foo"` keeps the closing quote in scope. Uses the shared
930
+ # `advance_js_scan_state` family so comment/string handling matches every other JS-aware
931
+ # pass in this file.
932
+ def last_js_code_char_index(content)
933
+ state = nil
934
+ escaped = false
935
+ index = 0
936
+ result = nil
937
+
938
+ while index < content.length
939
+ char = content[index]
940
+ next_char = content[index + 1]
941
+ prev_state = state
942
+ # Capture the pre-advance position so `result` always points at `char`'s index,
943
+ # not the post-advance value that `advance_js_scan_state` may bump for `//`/`/*`/`*/`
944
+ # transitions. The comment-state guard below covers those transitions, but capturing
945
+ # explicitly makes the invariant obvious without relying on that coupling.
946
+ char_index = index
947
+ state, escaped, index = advance_js_scan_state(state, escaped, char, next_char, index)
948
+
949
+ in_comment_now = JS_COMMENT_STATES.include?(state)
950
+ was_in_comment = JS_COMMENT_STATES.include?(prev_state)
951
+ result = char_index if !in_comment_now && !was_in_comment && !char.match?(/\s/)
952
+
953
+ index += 1
954
+ end
955
+
956
+ result
957
+ end
958
+
959
+ def rsc_client_references_setup_anchor?(content, is_server:)
960
+ !!rsc_client_references_setup_anchor_match(content, is_server: is_server)
961
+ end
962
+
963
+ # Called from the existing-config migration path, which is only reached after the
964
+ # generator has already confirmed `RSCWebpackPlugin` is imported in the file. That's why
965
+ # this helper deliberately omits the `RSCWebpackPlugin` import that `inject_rsc_*_imports`
966
+ # adds on the from-scratch path — adding it here would produce a duplicate import.
967
+ # Must only be called via `ensure_rsc_client_references_setup`, which has already verified
968
+ # that no module-scope `rscClientReferences` declaration exists and that the import anchor
969
+ # is present and not yet consumed by a previous call. Bypassing that guard can write a
970
+ # duplicate `const rscClientReferences` declaration and leave the user's webpack config with
971
+ # a Node SyntaxError at load time.
972
+ def add_rsc_client_references_setup(config_path, content, existing_imports_content, is_server:)
973
+ replace_rsc_client_references_setup_anchor(config_path, content, is_server: is_server) do |anchor|
974
+ join_rsc_client_references_setup(
975
+ content,
976
+ [
977
+ anchor,
978
+ # On the server path `shakapacker_config_blocker_reason` has already blocked when
979
+ # `config` is missing from `existing_imports_content`, so this call returns `nil`.
980
+ # Kept (rather than skipped on `is_server`) so removing the upstream blocker does not
981
+ # silently drop the import on the client path, where its absence is permitted.
982
+ shakapacker_config_import_statement(existing_imports_content),
983
+ path_resolve_import_statement(existing_imports_content),
984
+ "",
985
+ rsc_client_references_js
986
+ ]
987
+ )
988
+ end
989
+ end
990
+
991
+ def replace_rsc_client_references_setup_anchor(config_path, content, is_server:)
992
+ anchor_match = rsc_client_references_setup_anchor_match(content, is_server: is_server)
993
+ return unless anchor_match
994
+
995
+ updated_content = content.dup
996
+ updated_content[anchor_match.begin(0)...anchor_match.end(0)] = yield anchor_match[0]
997
+ if options[:pretend]
998
+ # Pretend is handled here because this helper reports injection-specific wording;
999
+ # `write_existing_rsc_config` still handles pretend for direct rewrite calls.
1000
+ say_status(:pretend, "Would inject rscClientReferences into #{config_path}", :yellow)
1001
+ else
1002
+ write_existing_rsc_config(config_path, updated_content, action: :insert)
1003
+ end
1004
+ true
1005
+ end
1006
+
1007
+ def write_existing_rsc_config(config_path, content, action:)
1008
+ if options[:pretend]
1009
+ message =
1010
+ if action == :insert
1011
+ "Would inject rscClientReferences into #{config_path}"
1012
+ else
1013
+ "Would rewrite #{config_path}"
1014
+ end
1015
+ say_status(:pretend, message, :yellow)
1016
+ return true
1017
+ end
1018
+
1019
+ if options[:skip]
1020
+ say_status(:skip, config_path, :yellow)
1021
+ return true
1022
+ end
1023
+
1024
+ # Direct write is intentional: multi-point rewrites cannot be expressed as one
1025
+ # gsub_file call, so the raw write explicitly mirrors Thor's --skip behavior above.
1026
+ File.write(File.join(destination_root, config_path), content)
1027
+ say_status(action, config_path, :green)
1028
+ true
1029
+ end
1030
+
1031
+ def shakapacker_config_import_statement(existing_imports_content)
1032
+ return if shakapacker_config_imported?(existing_imports_content)
1033
+
1034
+ "const { config } = require('shakapacker');"
1035
+ end
1036
+
1037
+ def path_resolve_import_statement(existing_imports_content)
1038
+ return if path_resolve_imported?(existing_imports_content)
1039
+
1040
+ "const { resolve } = require('path');"
1041
+ end
1042
+
1043
+ # Unlike `shakapacker_config_import_statement` / `path_resolve_import_statement` which check
1044
+ # `existing_imports_content` (the slice up through the anchor that they piggy-back on), this
1045
+ # helper checks the FULL file content. A user's existing `RSCWebpackPlugin` import can sit
1046
+ # below the anchor (e.g. a partially-edited or previously-failed migration), so dedup must
1047
+ # see the whole file or it will inject a duplicate `const { RSCWebpackPlugin } = require(...)`
1048
+ # and webpack will fail to load with `Identifier 'RSCWebpackPlugin' has already been declared`.
1049
+ def rsc_webpack_plugin_import_statement(content)
1050
+ return if commonjs_named_imported?(content, "react-on-rails-rsc/WebpackPlugin", "RSCWebpackPlugin")
1051
+
1052
+ "const { RSCWebpackPlugin } = require('react-on-rails-rsc/WebpackPlugin');"
1053
+ end
1054
+
1055
+ def rsc_client_references_setup_import_pattern(is_server:)
1056
+ if is_server
1057
+ # Matches the standard bundler ternary from the serverWebpackConfig template, including
1058
+ # harmless comments or extra whitespace around the ternary operators.
1059
+ # Rspack-only configs without the webpack fallback receive the manual-migration warning.
1060
+ js_gap = "(?:\\s|//[^\\n]*(?:\\n|\\r\\n?)|/\\*[\\s\\S]*?\\*/)*"
1061
+ Regexp.new(
1062
+ "(const\\s+bundler\\s*=\\s*config\\.assets_bundler#{js_gap}" \
1063
+ "===#{js_gap}['\"]rspack['\"]#{js_gap}" \
1064
+ "\\?#{js_gap}require\\(['\"]@rspack/core['\"]\\)#{js_gap}" \
1065
+ ":#{js_gap}require\\(['\"]webpack['\"]\\)#{js_gap};)"
1066
+ )
1067
+ else
1068
+ %r{(const commonWebpackConfig = require\(['"]\./commonWebpackConfig['"]\);)}
1069
+ end
1070
+ end
1071
+
1072
+ def rsc_client_references_defined?(content)
1073
+ # Module-scope guard mirrors the other detection helpers (e.g. `path_resolve_imported?`,
1074
+ # `shakapacker_config_imported?`) so a function-scoped `const rscClientReferences` does
1075
+ # not fool `ensure_rsc_client_references_setup` into skipping the helper injection — that
1076
+ # would leave the plugin rewrite referencing an out-of-scope binding.
1077
+ # `let` and `var` are matched alongside `const` because a hand-written
1078
+ # `let rscClientReferences = {...}` at module scope would otherwise slip past this check
1079
+ # and cause the migration to emit a second `const rscClientReferences = {...}`, producing
1080
+ # an `Identifier 'rscClientReferences' has already been declared` SyntaxError at config load.
1081
+ pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\b/
1082
+ content.to_enum(:scan, pattern).any? do
1083
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1084
+ end
1085
+ end
1086
+
1087
+ def scoped_rsc_client_references_defined?(content)
1088
+ # Locate the actual module-scope `const|let|var rscClientReferences = { ... }` site and
1089
+ # check the `directory:` key against the object literal body with comments stripped.
1090
+ # Running the regex against the raw file would treat a stale, commented-out
1091
+ # `// directory: resolve(config.source_path)` (e.g. left over from a prior failed
1092
+ # migration) as a real scoped declaration and silently short-circuit
1093
+ # `ensure_rsc_client_references_setup`, leaving the plugin unscoped without any warning.
1094
+ decl_pattern = /^[ \t]*(?:const|let|var)\s+rscClientReferences\s*=\s*\{/
1095
+ content.to_enum(:scan, decl_pattern).any? do
1096
+ match = Regexp.last_match
1097
+ next false unless js_top_level_position?(content, match.begin(0))
1098
+
1099
+ open_brace = match.end(0) - 1
1100
+ close_brace = matching_js_closing_brace(content, open_brace)
1101
+ next false unless close_brace
1102
+
1103
+ body = content[(open_brace + 1)...close_brace]
1104
+ rsc_plugin_options_without_comments(body)
1105
+ .match?(/\bdirectory\s*:\s*resolve\(\s*config\.source_path\s*\)/)
1106
+ end
1107
+ end
1108
+
1109
+ # Inclusive slice — the anchor itself is part of the returned content because callers
1110
+ # (`rsc_setup_blocked_by_later_imports?`, `inject_rsc_*_imports`, the import-detection
1111
+ # helpers) check whether required imports appear up to and including the anchor line.
1112
+ # Anything past the anchor is considered "later" and disqualifies an otherwise-valid import.
1113
+ def content_through_rsc_setup_anchor(content, is_server:)
1114
+ anchor = rsc_client_references_setup_anchor_match(content, is_server: is_server)
1115
+ return "" unless anchor
1116
+
1117
+ content[0...anchor.end(0)]
1118
+ end
1119
+
1120
+ def rsc_client_references_setup_anchor_match(content, is_server:)
1121
+ pattern = rsc_client_references_setup_import_pattern(is_server: is_server)
1122
+ content.to_enum(:scan, pattern).each do
1123
+ match = Regexp.last_match
1124
+ return match if js_code_position?(content, match.begin(0))
1125
+ end
1126
+ nil
1127
+ end
1128
+
1129
+ def rsc_setup_blocked_by_later_imports?(
1130
+ config_path,
1131
+ content,
1132
+ existing_imports_content,
1133
+ is_server:,
1134
+ plugin_pending: false
1135
+ )
1136
+ reason = rsc_setup_blocker_reason(content, existing_imports_content, is_server: is_server)
1137
+ return false unless reason
1138
+
1139
+ manual_action =
1140
+ if plugin_pending
1141
+ "RSCWebpackPlugin will be added without scoped clientReferences; please add clientReferences manually."
1142
+ elsif content.include?("RSCWebpackPlugin")
1143
+ "Please add clientReferences manually."
1144
+ else
1145
+ "RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
1146
+ end
1147
+
1148
+ GeneratorMessages.add_warning(
1149
+ "Could not inject rscClientReferences into #{config_path}: #{reason}. " \
1150
+ "#{manual_action}"
1151
+ )
1152
+ true
1153
+ end
1154
+
1155
+ # Reports the first blocker that prevents the generator from injecting `rscClientReferences`.
1156
+ # Each branch returns a message specific enough that the user can act on it without re-deriving
1157
+ # the cause from a generic "imports unavailable" warning.
1158
+ def rsc_setup_blocker_reason(content, existing_imports_content, is_server:)
1159
+ path_resolve_blocker_reason(content, existing_imports_content) ||
1160
+ shakapacker_config_blocker_reason(content, existing_imports_content, is_server: is_server)
1161
+ end
1162
+
1163
+ def path_resolve_blocker_reason(content, existing_imports_content)
1164
+ return nil if path_resolve_imported?(existing_imports_content)
1165
+
1166
+ if top_level_resolve_binding?(content)
1167
+ "a top-level `resolve` binding already exists that would conflict with the injected " \
1168
+ "`const { resolve } = require('path')`"
1169
+ elsif path_resolve_imported?(content)
1170
+ "the `resolve` import from `path` appears after the expected anchor line — " \
1171
+ "move the import above it"
1172
+ end
1173
+ end
1174
+
1175
+ # Intentional server/client asymmetry: when shakapacker's `config` is absent from the
1176
+ # file entirely (not misplaced, not aliased), the client branch falls through and returns
1177
+ # `nil` so the generator proceeds and adds the import alongside `rscClientReferences`. The
1178
+ # server branch always blocks because the server anchor (`config.assets_bundler === 'rspack'`)
1179
+ # itself requires `config` to be in scope, so an absent shakapacker import is always a user
1180
+ # configuration problem there.
1181
+ def shakapacker_config_blocker_reason(content, existing_imports_content, is_server:)
1182
+ return nil if shakapacker_config_imported?(existing_imports_content)
1183
+
1184
+ shakapacker_anywhere = shakapacker_config_imported?(content)
1185
+
1186
+ if shakapacker_anywhere
1187
+ if is_server
1188
+ "shakapacker's `config` is imported after the bundler ternary anchor — " \
1189
+ "move the import above it"
1190
+ else
1191
+ "shakapacker's `config` is imported after the `commonWebpackConfig` anchor — " \
1192
+ "move the import above it"
1193
+ end
1194
+ elsif top_level_config_binding?(content)
1195
+ "a top-level `config` binding already exists that would conflict with the injected " \
1196
+ "`const { config } = require('shakapacker')`"
1197
+ elsif is_server
1198
+ "shakapacker's `config` is not imported in this file — add " \
1199
+ "`const { config } = require('shakapacker');` before the bundler ternary"
1200
+ end
1201
+ end
1202
+
1203
+ def shakapacker_config_imported?(content)
1204
+ return true if commonjs_named_imported?(content, "shakapacker", "config")
1205
+
1206
+ top_level_dot_access_import?(content,
1207
+ /^[ \t]*(?:const|let|var)\s+config\s*=\s*require\(['"]shakapacker['"]\)\.config/)
1208
+ end
1209
+
1210
+ def path_resolve_imported?(content)
1211
+ # Full-module imports (`const path = require('path')`) do not create the bare `resolve` binding
1212
+ # that rscClientReferences uses, so the generator may add a harmless named import alongside them.
1213
+ return true if commonjs_named_imported?(content, "path", "resolve")
1214
+
1215
+ top_level_dot_access_import?(content,
1216
+ /^[ \t]*(?:const|let|var)\s+resolve\s*=\s*require\(['"]path['"]\)\.resolve/)
1217
+ end
1218
+
1219
+ # Verifies that a regex match is at module-scope depth=0 to avoid false positives
1220
+ # from function-scoped `require` calls (which do not produce module-scope bindings).
1221
+ def top_level_dot_access_import?(content, pattern)
1222
+ content.to_enum(:scan, pattern).any? do
1223
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1224
+ end
1225
+ end
1226
+
1227
+ def top_level_resolve_binding?(content)
1228
+ top_level_binding?(content, "resolve")
1229
+ end
1230
+
1231
+ def top_level_config_binding?(content)
1232
+ top_level_binding?(content, "config")
1233
+ end
1234
+
1235
+ def rsc_client_references_setup_anchor_available?(config_path, content, is_server:, plugin_pending: false)
1236
+ return true if rsc_client_references_setup_anchor?(content, is_server: is_server)
1237
+
1238
+ warn_missing_rsc_client_references_anchor(config_path, plugin_pending: plugin_pending)
1239
+ false
1240
+ end
1241
+
1242
+ def rsc_client_references_setup_ready?(config_path, plugin_pending: false)
1243
+ # In pretend/skip modes nothing is written to disk, so reading the file back would
1244
+ # report the helper as missing and the caller would fall through to a misleading
1245
+ # "injection failed" warning. Reporting success here lets callers show what *would*
1246
+ # be added (e.g. `, clientReferences: rscClientReferences` in the pretend plugin
1247
+ # output) instead.
1248
+ return true if options[:pretend]
1249
+ return true if options[:skip]
1250
+ return true if scoped_rsc_client_references_defined?(File.read(File.join(destination_root, config_path)))
1251
+
1252
+ warn_rsc_client_references_injection_failed(config_path, plugin_pending: plugin_pending)
1253
+ false
1254
+ end
1255
+
1256
+ def warn_missing_rsc_client_references_anchor(config_path, plugin_pending: false)
1257
+ GeneratorMessages.add_warning(
1258
+ "Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was not found " \
1259
+ "(the generator looks for the single- or double-quoted CommonJS `require` anchor that the ROR " \
1260
+ "templates emit; backtick template-literal require paths and Rspack-only server configs without " \
1261
+ "the webpack fallback ternary must be migrated manually). " \
1262
+ "If your config uses ESM `import` syntax, the generator cannot migrate it automatically. " \
1263
+ "#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
1264
+ )
1265
+ end
1266
+
1267
+ def warn_rsc_client_references_injection_failed(config_path, plugin_pending: false)
1268
+ GeneratorMessages.add_warning(
1269
+ "Could not inject rscClientReferences into #{config_path}: expected webpack import anchor was found, " \
1270
+ "but the generated scoped helper setup was not written. " \
1271
+ "#{manual_rsc_plugin_action(config_path, plugin_pending: plugin_pending)}"
1272
+ )
1273
+ end
1274
+
1275
+ def manual_rsc_plugin_action(config_path, plugin_pending:)
1276
+ if plugin_pending
1277
+ "RSCWebpackPlugin was not added to #{config_path}; please add the plugin and clientReferences manually."
1278
+ else
1279
+ "Please add clientReferences manually."
1280
+ end
1281
+ end
1282
+
1283
+ def warn_unscoped_rsc_client_references_helper(config_path)
1284
+ GeneratorMessages.add_warning(
1285
+ "Skipped scoped clientReferences migration for #{config_path} because rscClientReferences already exists " \
1286
+ "but does not point to resolve(config.source_path). Please verify it manually."
1287
+ )
1288
+ end
1289
+
1290
+ def warn_missing_rsc_plugin_target(config_path, is_server:)
1291
+ GeneratorMessages.add_warning(
1292
+ "Could not update RSCWebpackPlugin in #{config_path}: no plugin options with isServer: #{is_server} " \
1293
+ "could be rewritten. Please add clientReferences manually. Dynamic or computed plugin options cannot be " \
1294
+ "verified automatically, so verify this file manually after adding clientReferences."
1295
+ )
1296
+ end
1297
+
1298
+ def commonjs_named_imported?(content, package_name, binding_name)
1299
+ content_without_comments = rsc_plugin_options_without_comments(content)
1300
+ pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}\s*=\s*require\(['"]#{Regexp.escape(package_name)}['"]\);?/
1301
+
1302
+ content_without_comments.to_enum(:scan, pattern).any? do |captures|
1303
+ # Module-scope check guards against false positives when the same destructuring
1304
+ # appears inside a function body (which does not produce a module-scope binding).
1305
+ next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
1306
+
1307
+ destructuring_declares_binding?(captures.first, binding_name)
1308
+ end
1309
+ end
1310
+
1311
+ def top_level_binding?(content, binding_name)
1312
+ direct_binding = top_level_direct_binding?(content, binding_name)
1313
+ destructured_binding = top_level_destructured_binding?(content, binding_name)
1314
+
1315
+ direct_binding || destructured_binding
1316
+ end
1317
+
1318
+ def top_level_direct_binding?(content, binding_name)
1319
+ binding_pattern = Regexp.escape(binding_name)
1320
+ pattern = /
1321
+ ^[ \t]*
1322
+ (?:
1323
+ (?:const|let|var)\s+#{binding_pattern}\b |
1324
+ function\s+#{binding_pattern}\s*\( |
1325
+ class\s+#{binding_pattern}\b
1326
+ )
1327
+ /x
1328
+
1329
+ content.to_enum(:scan, pattern).any? do
1330
+ js_top_level_position?(content, Regexp.last_match.begin(0))
1331
+ end
1332
+ end
1333
+
1334
+ def top_level_destructured_binding?(content, binding_name)
1335
+ content_without_comments = rsc_plugin_options_without_comments(content)
1336
+ pattern = /^[ \t]*(?:const|let|var)\s+\{([^}]*)\}/
1337
+
1338
+ content_without_comments.to_enum(:scan, pattern).any? do |captures|
1339
+ next false unless js_top_level_position?(content_without_comments, Regexp.last_match.begin(0))
1340
+
1341
+ destructuring_declares_binding?(captures.first, binding_name)
1342
+ end
1343
+ end
1344
+
1345
+ def destructuring_declares_binding?(bindings, binding_name)
1346
+ rsc_plugin_options_without_comments(bindings).split(",").any? do |binding|
1347
+ destructuring_binding_declares_name?(binding.strip, binding_name)
1348
+ end
1349
+ end
1350
+
1351
+ def destructuring_binding_declares_name?(binding, binding_name)
1352
+ binding_pattern = Regexp.escape(binding_name)
1353
+
1354
+ # Aliases (`config: alias`) do not provide the exact binding that rscClientReferences uses.
1355
+ # Self-aliases (`config: config`) and defaults (`config = fallback`, `config=fallback`) do.
1356
+ binding.match?(/\A#{binding_pattern}(?:\z|\s*=)/) ||
1357
+ binding.match?(/\A#{binding_pattern}\s*:\s*#{binding_pattern}(?:\z|\s*=)/)
1358
+ end
1359
+ end
1360
+ end
1361
+ end
1362
+ end