Skip to content

Commit 9d85ebd

Browse files
committed
Add comprehensive investigation document for PR #1972
1 parent 6db4436 commit 9d85ebd

File tree

1 file changed

+324
-0
lines changed

1 file changed

+324
-0
lines changed

PR_1972_INVESTIGATION.md

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
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

Comments
 (0)