Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ After a release, run `/update-changelog` in Claude Code to analyze commits, writ

#### Fixed

- **Client startup now recovers if initialization begins during `interactive` after `DOMContentLoaded` already fired**: React on Rails now still initializes the page when the client bundle starts in the browser timing window after `DOMContentLoaded` but before the document reaches `complete`. Fixes [Issue 3150](https://github.com/shakacode/react_on_rails/issues/3150). [PR 3151](https://github.com/shakacode/react_on_rails/pull/3151) by [ihabadham](https://github.com/ihabadham).
- **Doctor accepts TypeScript server bundle entrypoints**: `react_on_rails:doctor` now resolves common source entrypoint suffixes (`.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs`) before warning that the server bundle is missing, preventing false positives when apps use `server-bundle.ts`. [PR 3111](https://github.com/shakacode/react_on_rails/pull/3111) by [justin808](https://github.com/justin808).
- **Doctor no longer fails custom projects for a missing generated `bin/dev`**: `react_on_rails:doctor` now downgrades a missing official React on Rails `bin/dev` launcher from an error to a warning and adds explicit guidance when a custom `./dev` script is detected, so custom projects can pass diagnostics when their development setup is intentional. Fixes [Issue 3103](https://github.com/shakacode/react_on_rails/issues/3103). [PR 3117](https://github.com/shakacode/react_on_rails/pull/3117) by [justin808](https://github.com/justin808).

Expand Down
35 changes: 24 additions & 11 deletions packages/react-on-rails/src/pageLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,33 @@ function initializePageEventListeners(): void {
}
isPageLifecycleInitialized = true;

// Important: replacing this condition with `document.readyState !== 'loading'` is not valid
// As the core ReactOnRails needs to ensure that all component bundles are loaded and executed before hydrating them
// If the `document.readyState === 'interactive'`, it doesn't guarantee that deferred scripts are executed
// the `readyState` can be `'interactive'` while the deferred scripts are still being executed
// Which will lead to the error `"Could not find component registered with name <component name>"`
// It will happen if this line is reached before the component chunk is executed on browser and reached the line
// ReactOnRails.register({ Component });
// ReactOnRailsPro is resellient against that type of race conditions, but it won't wait for that state anyway
// As it immediately hydrates the components at the page as soon as its html and bundle is loaded on the browser
// See pageLifecycle.test.js for unit tests validating this logic
// Important: replacing this condition with `document.readyState !== 'loading'` is not valid for
// the core page-load sweep. During `interactive`, deferred/module scripts may still be executing,
// and a component chunk may not yet have reached `ReactOnRails.register({ Component })`.
// Starting hydration too early can trigger "Could not find component registered" errors.
//
// However, async or dynamically-injected scripts can start after DOMContentLoaded has already fired
// while the document is still `interactive`. In that case, waiting only for DOMContentLoaded can miss
// initialization entirely, so we recover on the later `complete` transition.
//
// ReactOnRailsPro's early hydration path is more resilient to the registration race because it can
// hydrate components as their HTML and bundles arrive, but this page lifecycle still powers the
// fallback page-load sweep. See pageLifecycle.test.js for regression coverage of both cases.
if (document.readyState === 'complete') {
setupPageNavigationListeners();
} else {
document.addEventListener('DOMContentLoaded', setupPageNavigationListeners);
const initialPageLoadHandler = (event: Event): void => {
if (event.type === 'readystatechange' && document.readyState !== 'complete') return;
document.removeEventListener('DOMContentLoaded', initialPageLoadHandler);
document.removeEventListener('readystatechange', initialPageLoadHandler);
setupPageNavigationListeners();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idempotency here relies entirely on the two removeEventListener calls above succeeding before either browser event can re-enter this handler. That's safe in practice (single-threaded JS, well-behaved browsers), but setupPageNavigationListeners itself has no guard — if it were ever called a second time it would attach duplicate turbo:render / turbolinks:render listeners and double-fire every subsequent page-loaded callback.

Consider adding a one-line guard at the top of setupPageNavigationListeners:

Suggested change
setupPageNavigationListeners();
setupPageNavigationListeners();
let isNavigationListenersSetUp = false;
function setupPageNavigationListeners(): void {
  if (isNavigationListenersSetUp) return;
  isNavigationListenersSetUp = true;
  // … rest of function

This makes the defence explicit rather than implicit.

};

document.addEventListener('DOMContentLoaded', initialPageLoadHandler);

if (document.readyState === 'interactive') {
document.addEventListener('readystatechange', initialPageLoadHandler);
}
}
}

Expand Down
58 changes: 42 additions & 16 deletions packages/react-on-rails/tests/pageLifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ describe('pageLifecycle', () => {
// eslint-disable-next-line global-require
const importPageLifecycle = () => require('../src/pageLifecycle.ts');

const getEventHandler = (eventName) =>
addEventListenerSpy.mock.calls.find((call) => call[0] === eventName)?.[1];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array.find returns the first matching call. If a future test registers multiple listeners for the same event name, this helper silently returns only the first one and the second would be untestable via this API. It's low-risk for the current test suite, but worth noting. Using findLast (or asserting on mock.calls.filter(...).length) for ordered scenarios would be more robust.


// Helper function to create navigation library mock
const createNavigationMock = (overrides = {}) => ({
debugTurbolinks: jest.fn(),
Expand Down Expand Up @@ -81,7 +84,7 @@ describe('pageLifecycle', () => {
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
});

it('should wait for DOMContentLoaded when when document.readyState is "interactive"', () => {
it('should wait for DOMContentLoaded when document.readyState is "interactive"', () => {
setReadyState('interactive');
const callback = jest.fn();
const { onPageLoaded } = importPageLifecycle();
Expand All @@ -92,6 +95,7 @@ describe('pageLifecycle', () => {
expect(callback).not.toHaveBeenCalled();
// Verify that a DOMContentLoaded listener was added when readyState is 'interactive'
expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition here. The symmetric negative assertion is missing from the loading state test directly below — consider adding:

// in the 'loading' test:
expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));

Since readystatechange is the distinguishing part of this fix, a negative assertion in the loading path makes the intent explicit and guards against accidentally adding it there in the future.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good — this assertion documents that readystatechange is registered for interactive state. The symmetric negative assertion is missing from the adjacent loading-state test. Consider adding there:

// in the 'loading' state test, after the existing DOMContentLoaded assertion:
expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));

This documents the intentional asymmetry: the fallback listener is unnecessary when readyState === 'loading' because DOMContentLoaded cannot have already fired.

});

it('should wait for DOMContentLoaded when document.readyState is "loading"', () => {
Expand Down Expand Up @@ -272,8 +276,10 @@ describe('pageLifecycle', () => {
// - It tries to hydrate components BEFORE step 5 completes
// - ComponentRegistry.get() throws "Could not find component registered with name"
//
// The fix: Use `readyState === 'complete'` to ensure we wait for DOMContentLoaded
// (which fires AFTER deferred scripts execute)
// The fix: do not initialize immediately during `interactive`.
// Wait for DOMContentLoaded (which fires AFTER deferred scripts execute),
// and separately recover on the later `complete` transition only if
// DOMContentLoaded was already missed before startup ran.

it('should NOT call callbacks immediately when readyState is "interactive" because deferred scripts may not have executed', () => {
// Simulate the state right after HTML parsing completes but before deferred scripts run
Expand All @@ -285,9 +291,9 @@ describe('pageLifecycle', () => {
const hydrateComponentCallback = jest.fn();
onPageLoaded(hydrateComponentCallback);

// CRITICAL: With the correct implementation (readyState === 'complete'),
// the callback should NOT be called immediately when readyState is 'interactive'.
// This gives deferred scripts time to execute and register components.
// CRITICAL: With the correct implementation, the callback should NOT be
// called immediately when readyState is 'interactive'. This gives deferred
// scripts time to execute and register components before hydration runs.
expect(hydrateComponentCallback).not.toHaveBeenCalled();

// Instead, a DOMContentLoaded listener should be added
Expand Down Expand Up @@ -324,7 +330,7 @@ describe('pageLifecycle', () => {

onPageLoaded(attemptHydration);

// With correct implementation: callback not called yet, so no error
// With the correct implementation: callback not called yet, so no error
expect(attemptHydration).not.toHaveBeenCalled();

// Simulate deferred script executing (this happens before DOMContentLoaded)
Expand All @@ -339,12 +345,12 @@ describe('pageLifecycle', () => {

// Now when DOMContentLoaded fires, the component is registered
// and hydration succeeds
domContentLoadedHandler();
domContentLoadedHandler(new Event('DOMContentLoaded'));
expect(attemptHydration).toHaveBeenCalled();
// No error thrown because componentRegistered is now true
});

it('should handle the case where readyState transitions from interactive to complete', () => {
it('should recover when DOMContentLoaded was already missed during interactive by waiting for complete', () => {
// Start in 'interactive' state (HTML parsed, deferred scripts may be running)
setReadyState('interactive');

Expand All @@ -356,18 +362,38 @@ describe('pageLifecycle', () => {
// Callback should not be called immediately at 'interactive'
expect(callback).not.toHaveBeenCalled();
expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
expect(addEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));

// Get the DOMContentLoaded handler
const domContentLoadedHandler = addEventListenerSpy.mock.calls.find(
(call) => call[0] === 'DOMContentLoaded',
)?.[1];
// Simulate the case where DOMContentLoaded already fired before startup installed its listener.
// The later transition to complete should still recover initialization.
const readyStateChangeHandler = getEventHandler('readystatechange');
expect(readyStateChangeHandler).toBeDefined();

// Simulate state transition: deferred scripts complete, then DOMContentLoaded fires
setReadyState('complete');
domContentLoadedHandler();
readyStateChangeHandler(new Event('readystatechange'));

// Now callback should have been called
// Now callback should have been called through the complete fallback
expect(callback).toHaveBeenCalledTimes(1);
});

it('should remove both listeners after DOMContentLoaded fires so a later complete cannot re-trigger', () => {
setReadyState('interactive');

const { onPageLoaded } = importPageLifecycle();
const callback = jest.fn();

onPageLoaded(callback);

const domContentLoadedHandler = getEventHandler('DOMContentLoaded');
expect(domContentLoadedHandler).toBeDefined();

domContentLoadedHandler(new Event('DOMContentLoaded'));
expect(callback).toHaveBeenCalledTimes(1);

// The handler's cleanup is what prevents the later readystatechange→complete
// event from firing the callback a second time in real browsers.
expect(removeEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('readystatechange', expect.any(Function));
});
Comment on lines +379 to +397
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test verifies that removeEventListener is called, but since removeEventListenerSpy is mocked to a no-op (mockImplementation(() => {})), the handler is still attached in JSDOM. The comment below the assertions acknowledges the gap, but the test currently does not prove that the double-trigger is actually prevented.

A stronger version would simulate firing readystatechange after DOMContentLoaded has already run and assert the callback count is still 1:

it('should remove both listeners after DOMContentLoaded fires so a later complete cannot re-trigger', () => {
  setReadyState('interactive');
  const { onPageLoaded } = importPageLifecycle();
  const callback = jest.fn();
  onPageLoaded(callback);

  // Restore real removeEventListener so cleanup actually works
  removeEventListenerSpy.mockRestore();

  const domContentLoadedHandler = getEventHandler('DOMContentLoaded');
  const readystateChangeHandler = getEventHandler('readystatechange');

  domContentLoadedHandler(new Event('DOMContentLoaded'));
  expect(callback).toHaveBeenCalledTimes(1);

  // Simulate the 'complete' transition that must not re-trigger
  setReadyState('complete');
  readystateChangeHandler(new Event('readystatechange'));
  expect(callback).toHaveBeenCalledTimes(1); // still 1, not 2
});

});
});
Loading