Skip to content

Commit 925d091

Browse files
justin808claude
andcommitted
Add doctor checks for unsafe async usage without Pro
Implements async usage detection in the doctor program to prevent race conditions when using :async loading strategy or javascript_pack_tag without React on Rails Pro. Key features: - Scans view files (.erb, .haml) for javascript_pack_tag with :async - Checks config for generated_component_packs_loading_strategy = :async - Skips validation when Pro is installed (async is safe and default) - Reports errors when async used without Pro or immediate_hydration - Reports warnings when immediate_hydration enabled but Pro not installed - Provides clear, actionable error messages with upgrade guidance Also updates generator template to comment out explicit generated_component_packs_loading_strategy setting, allowing defaults to "do the right thing" (:defer for non-Pro, :async for Pro). Comprehensive test coverage added for all scenarios. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c6e0630 commit 925d091

File tree

8 files changed

+408
-22
lines changed

8 files changed

+408
-22
lines changed

docs/api-reference/configuration.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,17 @@ ReactOnRails.configure do |config|
267267
config.make_generated_server_bundle_the_entrypoint = false
268268

269269
# Configuration for how generated component packs are loaded.
270-
# Options: :sync, :async, :defer
271-
# - :sync (default for Shakapacker < 8.2.0): Loads scripts synchronously
272-
# - :async (default for Shakapacker ≥ 8.2.0): Loads scripts asynchronously for better performance
273-
# - :defer: Defers script execution until after page load
274-
config.generated_component_packs_loading_strategy = :async
270+
# Options: :defer (default), :sync, :async (Pro only)
271+
#
272+
# - :defer (default for non-Pro, recommended): Scripts load in parallel but execute in order
273+
# after HTML parsing. Prevents race conditions where components render before registration.
274+
# - :sync: Scripts block HTML parsing while loading (not recommended, slowest option).
275+
# - :async (Pro only): Scripts load and execute as soon as available. Requires Pro's
276+
# immediate_hydration feature to prevent race conditions.
277+
#
278+
# Default: :defer (non-Pro) or :async (Pro with Shakapacker >= 8.2.0)
279+
# Note: Shakapacker >= 8.2.0 required for :async support
280+
config.generated_component_packs_loading_strategy = :defer
275281

276282
# DEPRECATED: Use `generated_component_packs_loading_strategy` instead.
277283
# Migration: `defer_generated_component_packs: true` → `generated_component_packs_loading_strategy: :defer`
@@ -289,11 +295,21 @@ ReactOnRails.configure do |config|
289295
# their server-rendered HTML reaches the client, without waiting for the full page load.
290296
# This improves time-to-interactive performance.
291297
#
298+
# - generated_component_packs_loading_strategy = :async: Pro-only loading strategy that
299+
# works with immediate_hydration to load component scripts asynchronously for maximum
300+
# performance. Without Pro, :async can cause race conditions where components attempt
301+
# to hydrate before their JavaScript is loaded.
302+
#
292303
# Example (in config/initializers/react_on_rails_pro.rb):
293304
# ReactOnRailsPro.configure do |config|
294305
# config.immediate_hydration = true
295306
# end
296307
#
308+
# # In config/initializers/react_on_rails.rb:
309+
# ReactOnRails.configure do |config|
310+
# config.generated_component_packs_loading_strategy = :async
311+
# end
312+
#
297313
# For more information, visit: https://www.shakacode.com/react-on-rails-pro
298314

299315
################################################################################

docs/core-concepts/auto-bundling-file-system-based-automated-bundle-generation.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,36 @@ config.auto_load_bundle = true
4747
> Example (dummy app): `auto_load_bundle` is set to `true` in the same initializer.
4848
> [Dummy initializer](https://github.com/shakacode/react_on_rails/blob/master/spec/dummy/config/initializers/react_on_rails.rb)
4949

50+
### Configure `generated_component_packs_loading_strategy`
51+
52+
The `generated_component_packs_loading_strategy` option controls how generated component packs are loaded in the browser. This affects the `async` and `defer` attributes on the `<script>` tags for your component bundles.
53+
54+
Available options:
55+
56+
- `:defer` (default, recommended): Scripts load in parallel but execute in order after HTML parsing. This prevents race conditions where components try to render before registration completes.
57+
- `:sync`: Scripts block HTML parsing while loading (not recommended, slowest option).
58+
- `:async`: Scripts load and execute as soon as available (**React on Rails Pro only**). Requires `immediate_hydration` to prevent race conditions.
59+
60+
**IMPORTANT**: The `:async` loading strategy requires React on Rails Pro. Without Pro, `:async` can cause race conditions where components attempt to hydrate before their JavaScript is fully loaded. Pro's `immediate_hydration` feature ensures components hydrate safely with async loading.
61+
62+
Configure in `config/initializers/react_on_rails.rb`:
63+
64+
```rb
65+
# For most applications (Community and Pro)
66+
config.generated_component_packs_loading_strategy = :defer
67+
68+
# For React on Rails Pro with immediate_hydration enabled
69+
# config.generated_component_packs_loading_strategy = :async
70+
```
71+
72+
**Default behavior:**
73+
74+
- Non-Pro: defaults to `:defer` (safe and performant)
75+
- Pro: defaults to `:async` (maximum performance with `immediate_hydration`)
76+
- Requires Shakapacker >= 8.2.0 for `:async` support
77+
78+
See the [Configuration Guide](../api-reference/configuration.md#generated_component_packs_loading_strategy) for more details.
79+
5080
### Location of generated files
5181

5282
Generated files will go to the following two directories:

docs/upgrading/release-notes/16.0.0.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,22 @@ _The image above demonstrates the dramatic performance improvement:_
4646

4747
- New configuration option `generated_component_packs_loading_strategy` replaces `defer_generated_component_packs`
4848
- Supports three loading strategies:
49-
- `:async` - Loads scripts asynchronously (default for Shakapacker ≥ 8.2.0)
50-
- `:defer` - Defers script execution until after page load (doesn't work well with Streamed HTML as it will wait for the full page load before hydrating the components)
51-
- `:sync` - Loads scripts synchronously (default for Shakapacker < 8.2.0) (better to upgrade to Shakapacker 8.2.0 and use `:async` strategy)
49+
- `:async` - Loads scripts asynchronously (**React on Rails Pro only**, default for Pro with Shakapacker ≥ 8.2.0). Requires `immediate_hydration` to prevent race conditions.
50+
- `:defer` - Defers script execution until after page load (default for non-Pro, doesn't work well with Streamed HTML as it will wait for the full page load before hydrating the components)
51+
- `:sync` - Loads scripts synchronously (not recommended, better to use `:defer` or upgrade to Pro for `:async`)
5252
- Improves page performance by optimizing how component packs are loaded
53+
- **IMPORTANT**: `:async` loading strategy requires React on Rails Pro. Without Pro, `:async` can cause race conditions where components attempt to hydrate before their JavaScript is fully loaded.
5354

5455
## Breaking Changes
5556

5657
### Component Hydration Changes
5758

5859
- The `defer_generated_component_packs` configuration has been deprecated. Use `generated_component_packs_loading_strategy` instead.
59-
- The `generated_component_packs_loading_strategy` defaults to `:async` for Shakapacker ≥ 8.2.0 and `:sync` for Shakapacker < 8.2.0.
60+
- The `generated_component_packs_loading_strategy` defaults:
61+
- **React on Rails Pro**: `:async` (requires Shakapacker ≥ 8.2.0)
62+
- **Non-Pro**: `:defer` (safe default that prevents race conditions)
6063
- The `immediate_hydration` configuration now defaults to `false`. **Note: `immediate_hydration` is a React on Rails Pro (licensed) feature.**
61-
- When `generated_component_packs_loading_strategy: :async` and `immediate_hydration: true` are configured together, they optimize component hydration. Components hydrate as soon as their code and server-rendered HTML are available, without waiting for the full page to load. This parallel processing significantly improves time-to-interactive by eliminating the traditional waterfall of waiting for page load before beginning hydration (It's critical for streamed HTML).
62-
64+
- When `generated_component_packs_loading_strategy: :async` and `immediate_hydration: true` are configured together (Pro only), they optimize component hydration. Components hydrate as soon as their code and server-rendered HTML are available, without waiting for the full page to load. This parallel processing significantly improves time-to-interactive by eliminating the traditional waterfall of waiting for page load before beginning hydration (It's critical for streamed HTML).
6365
- The previous need for deferring scripts to prevent race conditions has been eliminated due to improved hydration handling. Making scripts not defer is critical to execute the hydration scripts early before the page is fully loaded.
6466
- The `immediate_hydration` configuration (React on Rails Pro licensed feature) makes `react-on-rails` hydrate components immediately as soon as their server-rendered HTML reaches the client, without waiting for the full page load.
6567
- To enable optimized hydration, you can set `immediate_hydration: true` in your `config/initializers/react_on_rails.rb` file (requires React on Rails Pro license).
@@ -68,7 +70,6 @@ _The image above demonstrates the dramatic performance improvement:_
6870
- You can override this behavior for individual Redux stores by calling the `redux_store` helper with `immediate_hydration: true` or `immediate_hydration: false`, same as `react_component`.
6971

7072
- `ReactOnRails.reactOnRailsPageLoaded()` is now an async function:
71-
7273
- If you manually call this function to ensure components are hydrated (e.g., with async script loading), you must now await the promise it returns:
7374

7475
```js
@@ -87,7 +88,7 @@ _The image above demonstrates the dramatic performance improvement:_
8788

8889
- If you were previously using `defer_generated_component_packs: true`, use `generated_component_packs_loading_strategy: :defer` instead
8990
- If you were previously using `defer_generated_component_packs: false`, use `generated_component_packs_loading_strategy: :sync` instead
90-
- For optimal performance with Shakapacker ≥ 8.2.0, consider using `generated_component_packs_loading_strategy: :async`
91+
- For optimal performance with Shakapacker ≥ 8.2.0 and React on Rails Pro, use `generated_component_packs_loading_strategy: :async` (requires Pro license and `immediate_hydration` to prevent race conditions)
9192

9293
### ESM-only package
9394

lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,26 @@ ReactOnRails.configure do |config|
6969
# GENERATED COMPONENT PACKS LOADING STRATEGY
7070
################################################################################
7171
# Configure how generated component packs are loaded in the browser.
72-
# Options: :defer (default), :sync, :async
72+
# Options: :defer (default), :sync, :async (Pro only)
7373
#
74-
# - :defer (recommended for most apps): Scripts load in parallel but execute in order after HTML parsing
75-
# - :sync: Scripts block HTML parsing (not recommended)
76-
# - :async: Scripts load and execute as soon as available (requires React on Rails Pro for better performance)
74+
# - :defer (recommended): Scripts load in parallel but execute in order after HTML parsing.
75+
# This prevents race conditions where components try to render before registration completes.
76+
# - :sync: Scripts block HTML parsing while loading (not recommended, slowest option).
77+
# - :async: Scripts load and execute as soon as available (React on Rails Pro only).
78+
# Requires immediate_hydration to prevent race conditions.
7779
#
78-
# Default is :defer which provides good performance and prevents component registration race conditions.
79-
# For React on Rails Pro users, :async can provide better performance by loading scripts asynchronously.
80-
config.generated_component_packs_loading_strategy = :defer
80+
# IMPORTANT: :async loading strategy requires React on Rails Pro. Without Pro, :async can cause
81+
# race conditions where components attempt to hydrate before their JavaScript is fully loaded.
82+
# Pro's immediate_hydration feature ensures components hydrate safely with async loading.
83+
#
84+
# Default behavior (recommended - no configuration needed):
85+
# - Non-Pro: defaults to :defer (safe and performant)
86+
# - Pro: defaults to :async (maximum performance with immediate_hydration)
87+
#
88+
# Only uncomment if you need to override the default:
89+
# config.generated_component_packs_loading_strategy = :defer
90+
#
91+
# See: https://www.shakacode.com/react-on-rails/docs/guides/configuration.html#generated_component_packs_loading_strategy
8192

8293
################################################################################
8394
# REACT ON RAILS PRO FEATURES

lib/react_on_rails/doctor.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def check_development
173173
check_procfile_dev
174174
check_bin_dev_script
175175
check_gitignore
176+
check_async_usage
176177
end
177178

178179
def check_javascript_bundles
@@ -1144,6 +1145,94 @@ def safe_display_config_value(label, config, method_name)
11441145
checker.add_info(" #{label}: <error reading value: #{e.message}>")
11451146
end
11461147
end
1148+
1149+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
1150+
def check_async_usage
1151+
# When Pro is installed, async is fully supported and is the default behavior
1152+
# No need to check for async usage in this case
1153+
return if ReactOnRails::Utils.react_on_rails_pro?
1154+
1155+
async_issues = []
1156+
1157+
# Check 1: javascript_pack_tag with :async in view files
1158+
view_files_with_async = scan_view_files_for_async_pack_tag
1159+
unless view_files_with_async.empty?
1160+
async_issues << "javascript_pack_tag with :async found in view files:"
1161+
view_files_with_async.each do |file|
1162+
async_issues << " • #{file}"
1163+
end
1164+
end
1165+
1166+
# Check 2: generated_component_packs_loading_strategy = :async
1167+
if config_has_async_loading_strategy?
1168+
async_issues << "config.generated_component_packs_loading_strategy = :async in initializer"
1169+
end
1170+
1171+
return if async_issues.empty?
1172+
1173+
# Report errors if async usage is found without Pro
1174+
# Note: immediate_hydration alone is not sufficient - Pro is required for safe async usage
1175+
immediate_hydration_enabled = check_immediate_hydration_enabled?
1176+
1177+
if immediate_hydration_enabled
1178+
checker.add_warning("⚠️ Using :async without React on Rails Pro may cause race conditions")
1179+
async_issues.each { |issue| checker.add_warning(" #{issue}") }
1180+
checker.add_info(" 💡 immediate_hydration is enabled but Pro gem is not installed")
1181+
checker.add_info(" 💡 For production-safe async loading, upgrade to React on Rails Pro")
1182+
else
1183+
checker.add_error("🚫 :async usage detected without proper configuration")
1184+
async_issues.each { |issue| checker.add_error(" #{issue}") }
1185+
checker.add_info(" 💡 :async can cause race conditions. Options:")
1186+
checker.add_info(" 1. Upgrade to React on Rails Pro (recommended for :async support)")
1187+
checker.add_info(" 2. Change to :defer or :sync loading strategy")
1188+
checker.add_info(" 📖 https://www.shakacode.com/react-on-rails/docs/guides/configuration/")
1189+
end
1190+
end
1191+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
1192+
1193+
def scan_view_files_for_async_pack_tag
1194+
files_with_async = []
1195+
1196+
# Scan app/views for .erb and .haml files
1197+
view_patterns = ["app/views/**/*.erb", "app/views/**/*.haml"]
1198+
1199+
view_patterns.each do |pattern|
1200+
Dir.glob(pattern).each do |file|
1201+
next unless File.exist?(file)
1202+
1203+
content = File.read(file)
1204+
# Look for javascript_pack_tag with :async or "async"
1205+
if content.match?(/javascript_pack_tag.*:async/) || content.match?(/javascript_pack_tag.*["']async["']/)
1206+
files_with_async << relativize_path(file)
1207+
end
1208+
end
1209+
end
1210+
1211+
files_with_async
1212+
rescue StandardError
1213+
[]
1214+
end
1215+
1216+
def config_has_async_loading_strategy?
1217+
config_path = "config/initializers/react_on_rails.rb"
1218+
return false unless File.exist?(config_path)
1219+
1220+
content = File.read(config_path)
1221+
# Check if generated_component_packs_loading_strategy is set to :async
1222+
content.match?(/config\.generated_component_packs_loading_strategy\s*=\s*:async/)
1223+
rescue StandardError
1224+
false
1225+
end
1226+
1227+
def check_immediate_hydration_enabled?
1228+
# Check if immediate_hydration is enabled in configuration
1229+
return false unless defined?(ReactOnRails)
1230+
1231+
config = ReactOnRails.configuration
1232+
config.immediate_hydration == true
1233+
rescue StandardError
1234+
false
1235+
end
11471236
end
11481237
# rubocop:enable Metrics/ClassLength
11491238
end

packages/react-on-rails/src/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { ReactOnRailsInternal, RailsContext } from './types/index.ts';
22

33
declare global {
4-
/* eslint-disable vars-on-top,no-var,no-underscore-dangle */
4+
/* eslint-disable vars-on-top,no-underscore-dangle */
55
var ReactOnRails: ReactOnRailsInternal | undefined;
66
var __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__: boolean;
7-
/* eslint-enable vars-on-top,no-var,no-underscore-dangle */
7+
/* eslint-enable vars-on-top,no-underscore-dangle */
88
}
99

1010
let currentRailsContext: RailsContext | null = null;

0 commit comments

Comments
 (0)