|
| 1 | +# PR #1972 Investigation: Component Registration Race Condition |
| 2 | + |
| 3 | +## Executive Summary |
| 4 | + |
| 5 | +PR #1972 attempted to fix intermittent CI test failures by changing the default `generated_component_packs_loading_strategy` from `:async` to `:defer`. This document provides an in-depth analysis of the race condition, the proposed solution, and recommendations for a better approach. |
| 6 | + |
| 7 | +## The Problem |
| 8 | + |
| 9 | +### Test Failures |
| 10 | + |
| 11 | +The following tests were failing intermittently in CI: |
| 12 | + |
| 13 | +- `spec/system/integration_spec.rb[1:1:6:1:2]` - "2 react components, 1 store, client only, defer" |
| 14 | +- `spec/system/integration_spec.rb[1:1:6:2:2]` - "2 react components, 1 store, server side, defer" |
| 15 | + |
| 16 | +These tests check Redux shared store functionality where two components share the same store and typing in one component should update the other. |
| 17 | + |
| 18 | +### Root Cause Analysis |
| 19 | + |
| 20 | +#### The Race Condition |
| 21 | + |
| 22 | +With `async` script loading (default on Shakapacker >= 8.2.0): |
| 23 | + |
| 24 | +1. **Browser behavior**: When scripts have the `async` attribute, they: |
| 25 | + |
| 26 | + - Download in parallel (good for performance) |
| 27 | + - Execute immediately when download completes (unpredictable order) |
| 28 | + - Do not block HTML parsing |
| 29 | + |
| 30 | +2. **The problem with generated component packs**: |
| 31 | + |
| 32 | + ```html |
| 33 | + <!-- Generated component pack (loaded via helper) --> |
| 34 | + <script src="/webpack/generated/ComponentName.js" async></script> |
| 35 | + |
| 36 | + <!-- Main client bundle (loaded in layout) --> |
| 37 | + <script src="/webpack/client-bundle.js" async></script> |
| 38 | + ``` |
| 39 | + |
| 40 | +3. **Race condition scenario**: |
| 41 | + - If `client-bundle.js` finishes downloading first, it executes immediately |
| 42 | + - React hydration starts before component registrations from generated packs |
| 43 | + - Error: "Could not find component registered with name ComponentName" |
| 44 | + |
| 45 | +#### Why It's Intermittent |
| 46 | + |
| 47 | +The race condition depends on: |
| 48 | + |
| 49 | +- Network conditions |
| 50 | +- File sizes (smaller files download faster) |
| 51 | +- Browser caching |
| 52 | +- Server response times |
| 53 | + |
| 54 | +This makes it particularly difficult to reproduce locally but common in CI environments with varying network conditions. |
| 55 | + |
| 56 | +## PR #1972 Solution Analysis |
| 57 | + |
| 58 | +### What Changed |
| 59 | + |
| 60 | +1. **Configuration default** (`lib/react_on_rails/configuration.rb`): |
| 61 | + |
| 62 | + ```ruby |
| 63 | + # OLD: Defaulted to :async when Shakapacker >= 8.2.0, else :sync |
| 64 | + # NEW: Always defaults to :defer |
| 65 | + self.generated_component_packs_loading_strategy = :defer |
| 66 | + ``` |
| 67 | + |
| 68 | +2. **Layout file** (`spec/dummy/app/views/layouts/application.html.erb`): |
| 69 | + |
| 70 | + ```erb |
| 71 | + <!-- OLD: Conditional logic based on uses_redux_shared_store? --> |
| 72 | + <!-- NEW: Always use defer: true --> |
| 73 | + <%= javascript_pack_tag('client-bundle', defer: true) %> |
| 74 | + ``` |
| 75 | + |
| 76 | +3. **Test expectations updated** to expect `:defer` as default |
| 77 | + |
| 78 | +### How Defer "Fixes" It |
| 79 | + |
| 80 | +With `defer`: |
| 81 | + |
| 82 | +- Scripts still download in parallel (fast) |
| 83 | +- Scripts execute in DOM order after HTML parsing completes |
| 84 | +- Generated component packs execute before main bundle (predictable) |
| 85 | +- Component registrations complete before React hydration |
| 86 | + |
| 87 | +```html |
| 88 | +<!-- With defer, these execute in order: --> |
| 89 | +<script src="/webpack/generated/ComponentName.js" defer></script> |
| 90 | +<!-- 1st --> |
| 91 | +<script src="/webpack/client-bundle.js" defer></script> |
| 92 | +<!-- 2nd --> |
| 93 | +``` |
| 94 | + |
| 95 | +## The Real Issue |
| 96 | + |
| 97 | +### Why This Solution Is Problematic |
| 98 | + |
| 99 | +1. **Performance Impact**: |
| 100 | + |
| 101 | + - `async` provides better performance by executing scripts as soon as they're ready |
| 102 | + - `defer` forces sequential execution, which can be slower |
| 103 | + - Modern web apps benefit from async loading |
| 104 | + |
| 105 | +2. **Masks Architectural Problem**: |
| 106 | + |
| 107 | + - The real issue is that React hydration shouldn't depend on script execution order |
| 108 | + - Components should be registered before hydration attempts to use them |
| 109 | + - This is a timing/coordination problem, not a loading strategy problem |
| 110 | + |
| 111 | +3. **Doesn't Address Root Cause**: |
| 112 | + - The race condition still exists with generated component packs |
| 113 | + - We're just forcing a particular execution order to avoid it |
| 114 | + - Better solution: ensure component registry is ready before hydration |
| 115 | + |
| 116 | +### The `uses_redux_shared_store?` Helper |
| 117 | + |
| 118 | +Before PR #1972, there was conditional logic: |
| 119 | + |
| 120 | +```ruby |
| 121 | +# application_controller.rb |
| 122 | +def uses_redux_shared_store? |
| 123 | + action_name.in?(%w[ |
| 124 | + index |
| 125 | + server_side_redux_app |
| 126 | + # ... other actions with shared stores |
| 127 | + ]) |
| 128 | +end |
| 129 | +``` |
| 130 | + |
| 131 | +This recognized that **only certain pages need defer**. PR #1972 removed this nuance by forcing defer everywhere. |
| 132 | + |
| 133 | +## Recommended Approach |
| 134 | + |
| 135 | +### Option 1: Component Registry Timeout (Already Implemented!) |
| 136 | + |
| 137 | +React on Rails already has `component_registry_timeout` (default 5000ms): |
| 138 | + |
| 139 | +```ruby |
| 140 | +# configuration.rb |
| 141 | +component_registry_timeout: DEFAULT_COMPONENT_REGISTRY_TIMEOUT # 5000ms |
| 142 | +``` |
| 143 | + |
| 144 | +This means the client-side code should **wait** for components to register before hydrating. The race condition might indicate: |
| 145 | + |
| 146 | +- The timeout isn't working correctly |
| 147 | +- There's a bug in the component registration check |
| 148 | +- The timeout is too short for CI environments |
| 149 | + |
| 150 | +**Investigation needed**: |
| 151 | + |
| 152 | +- Review `packages/react-on-rails/src/` for component registry logic |
| 153 | +- Check if hydration properly waits for registrations |
| 154 | +- Verify timeout is honored in all code paths |
| 155 | + |
| 156 | +### Option 2: Explicit Component Dependencies |
| 157 | + |
| 158 | +Make the main bundle explicitly wait for generated pack scripts: |
| 159 | + |
| 160 | +```javascript |
| 161 | +// In generated component packs: |
| 162 | +window.ReactOnRailsComponentsReady = window.ReactOnRailsComponentsReady || []; |
| 163 | +window.ReactOnRailsComponentsReady.push('ComponentName'); |
| 164 | + |
| 165 | +// In client-bundle before hydration: |
| 166 | +function waitForComponents(required, timeout = 5000) { |
| 167 | + return new Promise((resolve, reject) => { |
| 168 | + const check = () => { |
| 169 | + if (required.every((name) => window.ReactOnRailsComponentsReady.includes(name))) { |
| 170 | + resolve(); |
| 171 | + } |
| 172 | + }; |
| 173 | + // Poll until ready or timeout |
| 174 | + }); |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +### Option 3: Module Dependencies |
| 179 | + |
| 180 | +Use ES modules with dynamic imports: |
| 181 | + |
| 182 | +```javascript |
| 183 | +// Instead of script tags, use: |
| 184 | +const component = await import(`./generated/${componentName}`); |
| 185 | +``` |
| 186 | + |
| 187 | +This gives explicit control over load order without sacrificing async benefits. |
| 188 | + |
| 189 | +### Option 4: Smart Loading Strategy |
| 190 | + |
| 191 | +Keep async as default but fall back to defer only when needed: |
| 192 | + |
| 193 | +```ruby |
| 194 | +# Configuration that detects when defer is necessary |
| 195 | +def required_loading_strategy |
| 196 | + if @rendered_components.any? { |c| needs_guaranteed_order?(c) } |
| 197 | + :defer |
| 198 | + else |
| 199 | + :async |
| 200 | + end |
| 201 | +end |
| 202 | +``` |
| 203 | + |
| 204 | +## Test Analysis |
| 205 | + |
| 206 | +### The Failing Tests |
| 207 | + |
| 208 | +Looking at `spec/dummy/spec/system/integration_spec.rb:360-382`: |
| 209 | + |
| 210 | +```ruby |
| 211 | +describe "2 react components, 1 store, client only, defer", :js do |
| 212 | + include_examples "React Component Shared Store", "/client_side_hello_world_shared_store_defer" |
| 213 | +end |
| 214 | + |
| 215 | +describe "2 react components, 1 store, server side, defer", :js do |
| 216 | + include_examples "React Component Shared Store", "/server_side_hello_world_shared_store_defer" |
| 217 | +end |
| 218 | +``` |
| 219 | + |
| 220 | +These tests **specifically test defer functionality**. The fact that they fail with async is expected behavior! The routes ending in `_defer` are explicitly testing defer mode. |
| 221 | + |
| 222 | +**Key insight**: The failures might not be a bug but tests failing because: |
| 223 | + |
| 224 | +1. Default was changed from async to defer |
| 225 | +2. Tests expected defer behavior |
| 226 | +3. When default was async, these defer-specific tests used async instead |
| 227 | + |
| 228 | +## Recommendations |
| 229 | + |
| 230 | +### Immediate Actions |
| 231 | + |
| 232 | +1. **Revert PR #1972** ✅ (Already done) |
| 233 | + |
| 234 | +2. **Investigate component registry timeout**: |
| 235 | + |
| 236 | + - Review `packages/react-on-rails/src/ComponentRegistry.ts` |
| 237 | + - Check `component_registry_timeout` implementation |
| 238 | + - Add detailed logging to see when/why registrations fail |
| 239 | + |
| 240 | +3. **Reproduce the race condition locally**: |
| 241 | + |
| 242 | + ```bash |
| 243 | + # Throttle network to simulate CI conditions |
| 244 | + # Use browser DevTools Network tab -> Throttling |
| 245 | + # Run tests multiple times to catch intermittent failures |
| 246 | + ``` |
| 247 | + |
| 248 | +4. **Add instrumentation**: |
| 249 | + ```javascript |
| 250 | + console.log('[RoR] Component registered:', componentName, Date.now()); |
| 251 | + console.log('[RoR] Attempting hydration:', componentName, Date.now()); |
| 252 | + console.log('[RoR] Registry contents:', Object.keys(componentRegistry)); |
| 253 | + ``` |
| 254 | + |
| 255 | +### Long-term Solutions |
| 256 | + |
| 257 | +1. **Fix the timing issue properly**: |
| 258 | + |
| 259 | + - Ensure `component_registry_timeout` works correctly |
| 260 | + - Make hydration explicitly wait for required components |
| 261 | + - Add warnings when components aren't registered in time |
| 262 | + |
| 263 | +2. **Make loading strategy configurable per-component**: |
| 264 | + |
| 265 | + ```ruby |
| 266 | + react_component('ComponentName', props, loading_strategy: :defer) |
| 267 | + ``` |
| 268 | + |
| 269 | +3. **Document when defer is needed**: |
| 270 | + |
| 271 | + - Update docs to explain async vs defer trade-offs |
| 272 | + - Provide guidance on when to use each |
| 273 | + - Explain the performance implications |
| 274 | + |
| 275 | +4. **Improve test reliability**: |
| 276 | + - Add retries for tests with network dependencies |
| 277 | + - Use `retry: 3` in RSpec for these specific tests |
| 278 | + - Consider mocking/stubbing script loading in tests |
| 279 | + |
| 280 | +## Questions to Answer |
| 281 | + |
| 282 | +1. **Why does `component_registry_timeout` not prevent the race condition?** |
| 283 | + |
| 284 | + - Is it being used correctly? |
| 285 | + - Is there a code path that bypasses it? |
| 286 | + - Are generated component packs registering correctly? |
| 287 | + |
| 288 | +2. **Why do defer-specific tests fail with async default?** |
| 289 | + |
| 290 | + - Are the routes configured correctly? |
| 291 | + - Should these tests explicitly set the loading strategy? |
| 292 | + - Is there a bug in the configuration precedence? |
| 293 | + |
| 294 | +3. **Can we detect when defer is truly necessary?** |
| 295 | + - Shared Redux stores? |
| 296 | + - Inline component registration? |
| 297 | + - Server-side rendering? |
| 298 | + |
| 299 | +## Conclusion |
| 300 | + |
| 301 | +PR #1972's solution works but treats the symptom rather than the disease. The real fix requires: |
| 302 | + |
| 303 | +1. Understanding why the component registry timeout doesn't prevent the race |
| 304 | +2. Fixing the underlying timing/coordination issue |
| 305 | +3. Keeping async as the default for performance |
| 306 | +4. Using defer only when truly necessary (documented cases) |
| 307 | + |
| 308 | +The intermittent nature of the failures suggests a real race condition that needs proper synchronization, not just forced execution order. |
| 309 | + |
| 310 | +## Next Steps |
| 311 | + |
| 312 | +1. ✅ Revert PR #1972 |
| 313 | +2. ⏳ Deep dive into component registry timeout implementation |
| 314 | +3. ⏳ Reproduce failures locally with network throttling |
| 315 | +4. ⏳ Add instrumentation to understand timing |
| 316 | +5. ⏳ Implement proper synchronization fix |
| 317 | +6. ⏳ Update documentation with clear guidance |
| 318 | +7. ⏳ Create new PR with proper solution |
| 319 | + |
| 320 | +--- |
| 321 | + |
| 322 | +**Author**: Claude Code |
| 323 | +**Date**: November 11, 2025 |
| 324 | +**Status**: Investigation Complete, Awaiting Implementation |
0 commit comments