Skip to content

Fix initial page startup race for late-loading client bundles#3151

Open
ihabadham wants to merge 5 commits intomainfrom
fix/page-lifecycle-complete-fallback
Open

Fix initial page startup race for late-loading client bundles#3151
ihabadham wants to merge 5 commits intomainfrom
fix/page-lifecycle-complete-fallback

Conversation

@ihabadham
Copy link
Copy Markdown
Collaborator

@ihabadham ihabadham commented Apr 15, 2026

Summary

Fixes a client lifecycle gap where React on Rails can miss the initial page startup if the bundle begins executing after DOMContentLoaded has already fired but before the document reaches complete.

Fixes #3150

Problem

Core startup currently initializes immediately only when document.readyState === "complete", and otherwise waits for DOMContentLoaded.

That avoids starting too early during interactive, when deferred or module scripts may still be registering components.

But there is another browser timing window:

  • the bundle starts while document.readyState === "interactive"
  • DOMContentLoaded has already fired
  • the document is not yet "complete"

In that case, the DOMContentLoaded listener is attached too late, so the initial page-load callbacks never run.

What changed

  • Recover the initial page load when startup begins during interactive after DOMContentLoaded has already fired.
  • Coordinate manual ReactOnRails.reactOnRailsPageLoaded() calls with deferred startup so the same initial page load is not processed twice.
  • Preserve the existing Turbo/Turbolinks listener timing for the interactive recovery path.

Why this approach

The fix needs to handle both sides of the lifecycle correctly:

  • not start too early during interactive, before deferred registration is finished
  • still recover if DOMContentLoaded was already missed before startup attached its listener

Test plan

  • Verified initial startup now runs when the client bundle begins during interactive after DOMContentLoaded has already fired.
  • Added regression coverage for the missed-startup timing window.
  • Added regression coverage for manual ReactOnRails.reactOnRailsPageLoaded() during deferred startup so the initial page load is not processed twice.
  • Verified Turbo listener installation still happens at the later ready transition after a manual initial load during interactive.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed client startup initialization when the page is in the interactive readyState so initialization is no longer missed.
    • Ensured the startup callback runs exactly once and that event listeners are cleaned up to prevent duplicate initialization.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 276aec64-febd-4d6a-aed4-6900240d4be1

📥 Commits

Reviewing files that changed from the base of the PR and between 05e43fb and 3c3ebcf.

📒 Files selected for processing (2)
  • packages/react-on-rails/src/pageLifecycle.ts
  • packages/react-on-rails/tests/pageLifecycle.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-on-rails/tests/pageLifecycle.test.js

Walkthrough

Initialize logic now defers setup during interactive but registers both DOMContentLoaded and a readystatechange -> complete fallback (when needed), runs setup idempotently once, and removes both listeners after firing to avoid duplicate initialization across document readiness transitions.

Changes

Cohort / File(s) Summary
Page Lifecycle
packages/react-on-rails/src/pageLifecycle.ts
Make listener registration defensive: when document.readyState === 'complete' run setup immediately; otherwise install an idempotent one-time DOMContentLoaded handler and—if starting in interactive—also install a readystatechange handler that triggers on transition to complete. Both handlers remove each other after running.
Tests
packages/react-on-rails/tests/pageLifecycle.test.js
Add getEventHandler() helper and strengthen tests: assert both DOMContentLoaded and readystatechange listeners are registered in interactive, simulate missed DOMContentLoaded by invoking only readystatechange after switching to complete, ensure setup runs exactly once, and verify cleanup calls removeEventListener for both events.
Changelog
CHANGELOG.md
Add “Fixed” entry noting client startup recovery when initialization begins during interactive after DOMContentLoaded has already fired (Issue #3150, PR 3151).

Sequence Diagram(s)

sequenceDiagram
    participant Client as ClientBundle
    participant Doc as document
    participant Setup as setupPageNavigationListeners()

    Client->>Doc: check readyState
    alt readyState === "complete"
        Client->>Setup: run setup immediately
        Setup-->>Client: done
    else
        Client->>Doc: addEventListener("DOMContentLoaded", handler) 
        alt readyState === "interactive" (initial)
            Client->>Doc: addEventListener("readystatechange", readyStateHandler)
        end
        Note over Doc,Setup: One of the handlers fires
        Doc-->>Setup: DOMContentLoaded or readystatechange->complete
        Setup-->>Client: run setup (idempotent)
        Setup->>Doc: removeEventListener("DOMContentLoaded", handler)
        Setup->>Doc: removeEventListener("readystatechange", readyStateHandler)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I bounded in where readyStates hide,
Added a fallback to catch the missed tide,
Two listeners watch, then tidy and cease—
Setup hops once, and the race finds peace.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix initial page startup race for late-loading client bundles' accurately reflects the main change—addressing a race condition where page startup is missed when the client bundle loads during the interactive readyState window.
Linked Issues check ✅ Passed The PR implementation fully satisfies issue #3150's requirements: it handles all three readyState paths (loading, interactive, complete), recovers missed DOMContentLoaded during interactive via readystatechange fallback, and prevents the deferred registration race.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the pageLifecycle startup race: modifications to initializePageEventListeners(), updated tests for the new behavior, and a changelog entry—no unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/page-lifecycle-complete-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 15, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (+0.1% 🔺)
react-on-rails/client bundled (gzip) (time) 62.76 KB (+0.1% 🔺)
react-on-rails/client bundled (brotli) 53.86 KB (+0.17% 🔺)
react-on-rails/client bundled (brotli) (time) 53.86 KB (+0.17% 🔺)
react-on-rails-pro/client bundled (gzip) 63.71 KB (+0.09% 🔺)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (+0.09% 🔺)
react-on-rails-pro/client bundled (brotli) 54.63 KB (-0.02% 🔽)
react-on-rails-pro/client bundled (brotli) (time) 54.63 KB (-0.02% 🔽)
registerServerComponent/client bundled (gzip) 127.53 KB (+0.05% 🔺)
registerServerComponent/client bundled (gzip) (time) 127.53 KB (+0.05% 🔺)
registerServerComponent/client bundled (brotli) 61.7 KB (+0.23% 🔺)
registerServerComponent/client bundled (brotli) (time) 61.7 KB (+0.23% 🔺)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.73 KB (-0.01% 🔽)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.73 KB (0%)

@ihabadham ihabadham changed the title Fix missed client startup during interactive after DOMContentLoaded Fix initial page startup race for late-loading client bundles Apr 15, 2026
Comment on lines +105 to +116
export function consumeInitialPageLoadIfNeeded(): boolean {
if (currentPageState !== 'initial') {
return false;
}

if (document.readyState === 'complete') {
cleanupInitialPageLoadListeners();
ensureNavigationListenersInstalled();
}

runPageLoadedCallbacks();
return true;
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.

There's an asymmetry here compared to handleAutomaticInitialPageLoad: when readyState is not 'complete', this function calls runPageLoadedCallbacks() without first calling ensureNavigationListenersInstalled(). Navigation listeners (Turbo/Turbolinks) are deferred until the automatic readystatechangecomplete path fires later.

This is intentional (the "defers turbo listener" test covers it), but the consequence is a narrow window where Turbo navigation events fired between this manual call and the complete transition would be missed. A brief comment here would help future readers understand the deliberate deferral.

Also worth noting: when called during interactive after DOMContentLoaded has already fired, the initialPageLoadReadyHandler registered in initializePageEventListeners() is a dead listener — DOMContentLoaded will never fire again. It stays registered in memory until the readystatechange handler fires and triggers cleanupInitialPageLoadListeners(). It's harmless, but it's worth a comment.

Suggested change
export function consumeInitialPageLoadIfNeeded(): boolean {
if (currentPageState !== 'initial') {
return false;
}
if (document.readyState === 'complete') {
cleanupInitialPageLoadListeners();
ensureNavigationListenersInstalled();
}
runPageLoadedCallbacks();
return true;
export function consumeInitialPageLoadIfNeeded(): boolean {
if (currentPageState !== 'initial') {
return false;
}
// Only install navigation listeners and clean up the pending DOM handlers if we are
// already at 'complete'. If we're still at 'interactive', defer listener installation
// until the automatic readystatechange→complete path fires (see initialPageLoadCompleteHandler
// in initializePageEventListeners). This intentionally leaves a narrow window where Turbo
// navigation events fired before 'complete' would be missed, but that window is negligible
// in practice.
if (document.readyState === 'complete') {
cleanupInitialPageLoadListeners();
ensureNavigationListenersInstalled();
}
runPageLoadedCallbacks();
return true;
}

Comment on lines +34 to +38
export function reactOnRailsPageLoaded() {
const startupWasPending = clientStartup();
if (startupWasPending) {
consumeInitialPageLoadIfNeeded();
return;
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.

There's a silent third case: startupWasPending = true but consumeInitialPageLoadIfNeeded() returns false. This can happen if currentPageState is already 'load' when reactOnRailsPageLoaded() is called for the first time (e.g., onPageLoaded already advanced the state before this function ran). In that case, onPageLoaded(runAutomaticPageLoad) inside clientStartup() immediately invokes runAutomaticPageLoad (because onPageLoaded fast-paths when state is 'load'), so the render already happened and no further action is needed.

This is correct but non-obvious — a comment here would help:

Suggested change
export function reactOnRailsPageLoaded() {
const startupWasPending = clientStartup();
if (startupWasPending) {
consumeInitialPageLoadIfNeeded();
return;
const startupWasPending = clientStartup();
if (startupWasPending) {
// clientStartup() just registered runAutomaticPageLoad via onPageLoaded.
// If the page lifecycle is already at 'load', onPageLoaded() invoked it immediately,
// so consumeInitialPageLoadIfNeeded() will return false and we're done.
// If the page lifecycle is still at 'initial', consumeInitialPageLoadIfNeeded()
// will advance it and trigger the render now.
consumeInitialPageLoadIfNeeded();
return;
}

Comment on lines +153 to +157
document.addEventListener('DOMContentLoaded', initialPageLoadReadyHandler);

if (document.readyState === 'interactive') {
document.addEventListener('readystatechange', initialPageLoadCompleteHandler);
}
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 readystatechange listener is only registered when readyState === 'interactive', not for 'loading'. This is correct — the "missed DOMContentLoaded" scenario only arises when the bundle starts during interactive. But a comment spelling this out would help:

Suggested change
document.addEventListener('DOMContentLoaded', initialPageLoadReadyHandler);
if (document.readyState === 'interactive') {
document.addEventListener('readystatechange', initialPageLoadCompleteHandler);
}
document.addEventListener('DOMContentLoaded', initialPageLoadReadyHandler);
// Only register the readystatechange fallback during 'interactive'. During 'loading',
// DOMContentLoaded is guaranteed to fire after deferred scripts run, so no fallback
// is needed. The fallback is specifically for the case where the bundle begins executing
// during 'interactive' after DOMContentLoaded has already fired.
if (document.readyState === 'interactive') {
document.addEventListener('readystatechange', initialPageLoadCompleteHandler);
}

delete globalThis.__REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__;
});

it('does not render twice when manual page load runs during interactive before deferred startup registration', async () => {
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 test name mentions "before deferred startup registration" but the actual scenario being tested is that reactOnRailsPageLoaded() is called before the setTimeout(() => clientStartup(), 0) in ReactOnRails.client.ts fires. Consider renaming for clarity:

Suggested change
it('does not render twice when manual page load runs during interactive before deferred startup registration', async () => {
it('does not render twice when manual reactOnRailsPageLoaded() is called before the deferred clientStartup() setTimeout fires', async () => {

Comment on lines +97 to +101
setReadyState('complete');
document.dispatchEvent(new Event('readystatechange'));
await waitForNextTick();

expect(counts).toEqual({ storeCalls: 1, rendererCalls: 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.

This dispatches the real readystatechange event via document.dispatchEvent, which will call the actual initialPageLoadCompleteHandler registered in initializePageEventListeners. The test correctly verifies the full end-to-end path, but note that addEventListenerSpy is not mocked here (it's a spy, not a stub), so the real listeners are actually registered and called. This is a good integration-level test.

One missing assertion: after the readystatechange fires, it might be worth verifying that the handlers are cleaned up (i.e., removeEventListener was called for both DOMContentLoaded and readystatechange). Otherwise a future regression that forgets to clean up listeners would slip past this test.

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 15, 2026

Code Review

Overview

This PR fixes a real browser timing gap: when a bundle starts executing during interactive after DOMContentLoaded has already fired, the previous code's single DOMContentLoaded listener would never trigger, silently skipping the initial page-load sweep. The fix adds a readystatechange fallback and coordinates manual reactOnRailsPageLoaded() calls so the same load isn't processed twice.

The logic is correct and the test coverage is solid. A few things worth discussing:


Logic / Correctness

consumeInitialPageLoadIfNeeded defers navigation listener installation

When called during interactive (e.g. manual reactOnRailsPageLoaded() call), the function runs runPageLoadedCallbacks() but skips ensureNavigationListenersInstalled(). Navigation listeners (Turbo/Turbolinks) aren't installed until the automatic readystatechange→complete transition later. The "defers turbo listener" test explicitly validates this.

The implied consequence: if a Turbo navigation fires between the manual call and the document reaching complete, React on Rails won't respond. That window is negligible in practice (the page is still loading), but it's worth a comment in the function to make the deliberate deferral clear. Inline comment left.

Dead DOMContentLoaded listener in the "already missed" case

When initializePageEventListeners() runs after DOMContentLoaded has already fired (interactive state), it registers an initialPageLoadReadyHandler on DOMContentLoaded that will never fire. It's cleaned up when readystatechange→complete triggers cleanupInitialPageLoadListeners(). No functional bug, but the listener sits in memory longer than necessary. Could be avoided by checking performance.timing or checking whether DOMContentLoaded has already fired — though that would add complexity for a harmless case.

Silent third case in reactOnRailsPageLoaded

When startupWasPending = true but consumeInitialPageLoadIfNeeded() returns false (state already 'load'), the render has already happened via onPageLoaded's fast-path. This is correct but non-obvious. Inline comment left suggesting a clarifying note.


Test Coverage

Good regression coverage for the two new scenarios. A few gaps:

  • No test for reactOnRailsPageLoaded() called during 'loading' state. In that state consumeInitialPageLoadIfNeeded() would fire runPageLoadedCallbacks() before deferred scripts have executed — exactly the race condition the original fix guards against. This is an unlikely caller pattern but worth documenting as out-of-scope or adding a test that verifies it doesn't regress.

  • pageLifecycle.test.js "should not initialize listeners multiple times" (line 248): that test is in 'loading' state and asserts addEventListenerSpy.toHaveBeenCalledTimes(1). With the new code, 'interactive' state would call addEventListener twice (DOMContentLoaded + readystatechange). The test is fine as-is but it only covers 'loading'; a parallel case for 'interactive' would make the count explicit.

  • Listener cleanup not asserted in clientStartup.test.js: after the readystatechange fires in the first two tests, there's no assertion that removeEventListener was called. A future regression that forgets cleanup would slip past. Inline comment left.


Minor

  • clientStartup() now returns a boolean but its name doesn't suggest it does. A JSDoc sentence on what true/false mean would help callers.
  • In pageLifecycle.ts line 155–157, the comment explaining why readystatechange is only registered for 'interactive' (not 'loading') is missing. Inline comment left with a suggestion.

Summary

The core fix is sound and the approach is the right one. The main ask is additional comments in consumeInitialPageLoadIfNeeded and the readystatechange registration block to make the deliberate design decisions self-documenting. Test gaps are minor and mostly documentation-level, but the listener-cleanup assertion gap in clientStartup.test.js is worth adding.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae74608764

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +35 to +38
const startupWasPending = clientStartup();
if (startupWasPending) {
consumeInitialPageLoadIfNeeded();
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve deferred startup when manual load runs at complete

Calling clientStartup() unconditionally at the start of reactOnRailsPageLoaded() changes option timing for async bundles that execute after document.readyState === "complete". In that flow, onPageLoaded(...) initializes immediately and locks navigation listener setup before a later ReactOnRails.setOptions({ turbo: true }) call in the same tick, and the deferred startup timer is then skipped because __REACT_ON_RAILS_EVENT_HANDLERS_RAN_ONCE__ is already set. The result is that Turbo listeners are never installed, so subsequent Turbo navigations miss unload/load lifecycle callbacks. Before this change, deferred startup still ran after setOptions and would install the listeners.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/react-on-rails/tests/clientStartup.test.js (1)

118-137: Consider whether setOptions timing reflects real usage.

The test sets turbo: true after reactOnRailsPageLoaded() completes. In practice, wouldn't users typically configure options before the page load runs?

If the intent is to verify that Turbo listeners are installed regardless of when the option is set (as long as it's before complete), this is fine. Otherwise, consider moving setOptions({ turbo: true }) before the reactOnRailsPageLoaded() call to better match real-world usage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails/tests/clientStartup.test.js` around lines 118 - 137,
The test currently calls ReactOnRails.setOptions({ turbo: true }) after
ReactOnRails.reactOnRailsPageLoaded(), which doesn't match typical usage; update
the test "defers turbo listener installation..." to call
ReactOnRails.setOptions({ turbo: true }) before invoking
ReactOnRails.reactOnRailsPageLoaded() (or alternatively add a new test that
asserts behavior when options are set post-load), so the setup reflects
real-world ordering and still verifies turbo listener installation on
'complete'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails/tests/clientStartup.test.js`:
- Around line 118-137: The test currently calls ReactOnRails.setOptions({ turbo:
true }) after ReactOnRails.reactOnRailsPageLoaded(), which doesn't match typical
usage; update the test "defers turbo listener installation..." to call
ReactOnRails.setOptions({ turbo: true }) before invoking
ReactOnRails.reactOnRailsPageLoaded() (or alternatively add a new test that
asserts behavior when options are set post-load), so the setup reflects
real-world ordering and still verifies turbo listener installation on
'complete'.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 69607a43-76e4-478a-9bf8-0658976b9d0e

📥 Commits

Reviewing files that changed from the base of the PR and between de5b53a and ae74608.

📒 Files selected for processing (4)
  • packages/react-on-rails/src/clientStartup.ts
  • packages/react-on-rails/src/pageLifecycle.ts
  • packages/react-on-rails/tests/clientStartup.test.js
  • packages/react-on-rails/tests/pageLifecycle.test.js

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 15, 2026

Greptile Summary

This PR fixes a browser timing gap where the React on Rails client startup misses the initial page load when a bundle begins executing during interactive after DOMContentLoaded has already fired. The fix adds a readystatechange fallback listener alongside the existing DOMContentLoaded listener, coordinates reactOnRailsPageLoaded() manual calls with deferred startup to prevent double-rendering, and installs navigation listeners (Turbo/Turbolinks) lazily on the later complete transition for the recovery path.

Confidence Score: 5/5

Safe to merge; all findings are P2 style or precision notes that don't affect correctness in practice.

The core logic is sound and well-tested. The three-branch initialization (complete/interactive/loading) correctly handles the missed-DOMContentLoaded window. Double-render prevention is verified by integration tests. Remaining issues are a missing return type annotation and a harmless spurious removeEventListener call — neither affects runtime behavior.

packages/react-on-rails/src/pageLifecycle.ts — review the deferred navigation listener window comment and the spurious removeEventListener note.

Important Files Changed

Filename Overview
packages/react-on-rails/src/pageLifecycle.ts Core fix: adds readystatechange fallback listener for interactive state, refactors navigation strategy detection, and exports consumeInitialPageLoadIfNeeded for startup coordination; two minor style/precision issues noted.
packages/react-on-rails/src/clientStartup.ts Rewires reactOnRailsPageLoaded to call clientStartup() and consumeInitialPageLoadIfNeeded for deduplication; missing return type annotation on the exported function.
packages/react-on-rails/tests/pageLifecycle.test.js Adds regression tests for both the missed-DOMContentLoaded recovery path and the double-initialization guard; helper getEventHandler improves test readability.
packages/react-on-rails/tests/clientStartup.test.js New integration test suite covering no-double-render during interactive startup, complete-state deduplication, and deferred Turbo listener installation timing.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Bundle executes] --> B{readyState?}

    B -->|complete| C[handleAutomaticInitialPageLoad\nimmediate path]
    B -->|interactive| D[Add DOMContentLoaded listener\nAdd readystatechange listener NEW]
    B -->|loading| E[Add DOMContentLoaded listener only]

    C --> F[ensureNavigationListenersInstalled]
    F --> G{turbolinks2?}
    G -->|no| H[runPageLoadedCallbacks\nrenders components]
    G -->|yes| I[Wait for page:change event]

    D --> J{DOMContentLoaded fires?}
    J -->|yes, caught| K[handleAutomaticInitialPageLoad\nnormal path]
    J -->|already missed| L[readystatechange → complete\nrecovery path NEW]
    K --> F
    L --> M[handleAutomaticInitialPageLoad\ncurrentPageState!=initial branch]
    M --> N[ensureNavigationListenersInstalled\ncleanup listeners]

    E --> O[DOMContentLoaded fires] --> K

    subgraph Manual ["reactOnRailsPageLoaded() manual call"]
        P[clientStartup] -->|first run, returns true| Q[consumeInitialPageLoadIfNeeded]
        P -->|already ran, returns false| R{currentPageState?}
        Q -->|readyState=complete| S[cleanup + install listeners\n+ runPageLoadedCallbacks]
        Q -->|readyState=interactive| T[runPageLoadedCallbacks\ndefer nav listeners to readystatechange]
        R -->|initial| S
        R -->|load| U[runAutomaticPageLoad\ndirect re-render]
    end
Loading

Reviews (1): Last reviewed commit: "Prevent duplicate page-load sweeps durin..." | Re-trigger Greptile

return true;
}

export function reactOnRailsPageLoaded() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing return type annotation

reactOnRailsPageLoaded is the only exported function in this file without an explicit return type, making it inconsistent with clientStartup(): boolean just above it.

Suggested change
export function reactOnRailsPageLoaded() {
export function reactOnRailsPageLoaded(): void {

Comment on lines 105 to 117
export function consumeInitialPageLoadIfNeeded(): boolean {
if (currentPageState !== 'initial') {
return false;
}

if (document.readyState === 'complete') {
cleanupInitialPageLoadListeners();
ensureNavigationListenersInstalled();
}

runPageLoadedCallbacks();
return true;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Navigation listeners not installed before runPageLoadedCallbacks() during interactive

When consumeInitialPageLoadIfNeeded is called while readyState === 'interactive' (the deferred-startup recovery path), the function skips cleanupInitialPageLoadListeners / ensureNavigationListenersInstalled and calls runPageLoadedCallbacks() immediately. Navigation listeners (Turbo turbo:render, Turbolinks events) are deferred until the subsequent readystatechangecomplete transition.

This is intentional and tested, but it creates a narrow window during the interactive → complete gap where a Turbo navigation would fire turbo:before-render / turbo:render without any listener installed, leaving components neither unmounted nor remounted. In practice a Turbo navigation during page-load is rare, but it may be worth adding a comment here so future readers understand why navigation listeners aren't installed at this call site.

Comment on lines +79 to +89
function cleanupInitialPageLoadListeners(): void {
if (initialPageLoadReadyHandler) {
document.removeEventListener('DOMContentLoaded', initialPageLoadReadyHandler);
initialPageLoadReadyHandler = null;
}

if (initialPageLoadCompleteHandler) {
document.removeEventListener('readystatechange', initialPageLoadCompleteHandler);
initialPageLoadCompleteHandler = null;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Spurious removeEventListener call when started in loading state

initialPageLoadCompleteHandler is always assigned in the else branch of initializePageEventListeners, but the readystatechange listener is only registered when readyState === 'interactive'. When startup happens during loading, cleanupInitialPageLoadListeners will call document.removeEventListener('readystatechange', initialPageLoadCompleteHandler) for a listener that was never added — a harmless no-op, but slightly imprecise. Consider guarding with the same readyState === 'interactive' check, or only assigning initialPageLoadCompleteHandler when the listener is actually registered.

ihabadham added a commit that referenced this pull request Apr 15, 2026
## Summary

- Applies `prettier --write` to five tracked docs files that are
currently out of compliance on `main`.
- No content changes — only whitespace/table-alignment fixes produced by
Prettier.

## Why this is needed

The formatting drift landed via a docs-only merge to `main`. The
`lint-js-and-ruby.yml` workflow is conditionally skipped for docs-only
commits on `main` (see the job condition at
[`.github/workflows/lint-js-and-ruby.yml:79-87`](https://github.com/shakacode/react_on_rails/blob/main/.github/workflows/lint-js-and-ruby.yml#L79-L87)),
so the check never ran on merge. It surfaces on every subsequent
code-touching PR because the lint job runs for those (e.g.
[#3151](#3151),
[#3148](#3148),
[#3142](#3142),
[#3097](#3097)).

Merging this unblocks the `build` → `Check formatting` step on those
PRs.

## Test plan

- [x] `pnpm start format.listDifferent` passes for the five files
- [x] Diff is purely whitespace — no link targets, code blocks, or prose
changed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Improved formatting across API reference, core concepts, migration,
and RSC docs: refined table layouts, adjusted column alignment, and
standardized emphasis for clearer warnings.
* Updated link text and phrasing for several guides for clearer
navigation.
* Reformatted code examples and JSX/Route snippets for more compact,
consistent presentation and improved readability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ihabadham ihabadham force-pushed the fix/page-lifecycle-complete-fallback branch from ae74608 to 05e43fb Compare April 15, 2026 20:10
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 15, 2026

Code Review

Overview

This PR fixes a real browser timing window: when the client bundle begins executing while document.readyState === 'interactive' and DOMContentLoaded has already fired, the existing code attaches a DOMContentLoaded listener that will never trigger, leaving the page uninitialized. The fix recovers by also listening for the later readystatechange → complete transition as a fallback.

The logic is correct and the approach is well-suited to the constraint (can't initialize early during interactive because deferred scripts may not have registered their components yet).


What works well

  • The isSetupComplete flag + cleanupListeners correctly prevents double-initialization across all four states (complete, loading, interactive-before-DCL, interactive-after-DCL).
  • The readystatechange listener is correctly scoped to the interactive branch only — when starting from loading, DOMContentLoaded can't have already fired, so the fallback is unnecessary.
  • New tests cover both the happy path and the idempotency guarantee.

Issues

1. Missing negative assertion for the loading state

The test "should wait for DOMContentLoaded when document.readyState is 'loading'" doesn't assert that readystatechange is not added. Since the readystatechange listener is the critical part of this fix, a guard against accidentally adding it in the loading branch would make the intent explicit and protect against future regressions.

expect(addEventListenerSpy).not.toHaveBeenCalledWith('readystatechange', expect.any(Function));

2. cleanupListeners references uninitialized let bindings at definition time

cleanupListeners is defined before setupPageNavigationListenersOnce and setupPageNavigationListenersOnComplete are assigned. The closure captures the variables by reference so it works at call time, but this is a subtle TDZ-adjacent pattern that can confuse readers. Reordering the assignments so everything is defined before cleanupListeners is created, or restructuring slightly, would make the code easier to reason about.

3. PR description claims coordination with manual reactOnRailsPageLoaded() calls — not reflected in the diff

Coordinate manual ReactOnRails.reactOnRailsPageLoaded() calls with deferred startup so the same initial page load is not processed twice.

Neither clientStartup.ts nor the test suite contains changes related to this scenario. If this coordination relies solely on the existing isPageLifecycleInitialized guard, that should be called out explicitly in a comment (and tested). If it's actually unaddressed, it should be removed from the PR description to avoid misleading future readers.

4. getEventHandler only returns the first matching listener

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

Array.find returns the first match. If a future test adds multiple listeners for the same event (e.g. two readystatechange handlers), this silently returns only the first. findLast or an assertion on the count would be safer, but this is low-risk for the current test suite.


Minor nits

  • The rename from "should handle the case where readyState transitions from interactive to complete""should recover when DOMContentLoaded was already missed during interactive by waiting for complete" is a clear improvement — the new name communicates intent rather than mechanics.
  • The updated comment block in pageLifecycle.ts accurately describes the constraint and both timing paths. Good documentation.

Verdict

The fix is sound and the test coverage substantially improves confidence in this tricky timing path. Addressing the missing negative assertion (issue 1) and clarifying the PR description (issue 3) are the main things worth resolving before merge.

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.

let setupPageNavigationListenersOnce: () => void;
let setupPageNavigationListenersOnComplete: () => void;

const cleanupListeners = (): void => {
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.

cleanupListeners closes over setupPageNavigationListenersOnce and setupPageNavigationListenersOnComplete, but both are still undefined at this point — they're only assigned on the lines that follow. This is safe at call time (the closures are never invoked before the assignments complete), but it's a subtle pattern that can trip up readers unfamiliar with JS closure/TDZ semantics.

One way to make it more self-evident: move cleanupListeners to after the two setupPageNavigation… assignments, or restructure so the function references are captured inline at the addEventListener call sites rather than through shared let bindings.

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.

Collapse the two closures + forward declarations + isSetupComplete flag
into a single handler registered for both DOMContentLoaded and
readystatechange. The handler filters readystatechange events by
readyState and removes both listeners in one shot before running setup.

The flag guarded against a scenario that cannot occur in real browsers:
removeEventListener is synchronous, so once the first event is handled
and both listeners are removed, the second event cannot re-invoke the
handler. The same cleanup now lives directly in the handler.

Update the "initialize once" test to verify the removeEventListener
calls (the actual mechanism that prevents the double-run) instead of
re-invoking handlers by reference, which bypassed the cleanup and
misrepresented real browser behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 15, 2026

Code Review: Fix initial page startup race for late-loading client bundles

Overall: The fix is well-reasoned and targeted. The dual-listener strategy cleanly handles the missed-DOMContentLoaded window without breaking the existing deferred-script protection. A few items worth addressing before merge.


What the fix does well

  • Minimal blast radius: Only the interactive-state path gains a new listener; loading and complete paths are unchanged.
  • Self-cleaning handler: Both listeners are removed before setupPageNavigationListeners is called, so the handler itself is idempotent.
  • Clear comments: The updated block comment accurately describes all three timing cases and the tradeoff.
  • Test coverage: Four targeted tests cover normal DOMContentLoaded, the missed-DOMContentLoaded recovery, and the cleanup assertion.

Issues

1. Test for double-trigger prevention is incomplete (medium)

The test "should remove both listeners after DOMContentLoaded fires so a later complete cannot re-trigger" (~line 379) asserts that removeEventListener was called, but removeEventListenerSpy is mocked to a no-op, so the listener is still active in the JSDOM environment. The test does not verify that a subsequent readystatechange event would be ignored — it only proves cleanup was attempted. A stronger test would fire readystatechange after DOMContentLoaded and assert the callback count remains at 1.

2. setupPageNavigationListeners has no idempotency guard (minor)

Correctness relies entirely on removeEventListener deregistering the handler before either event can fire a second time. In practice this is fine, but setupPageNavigationListeners has no internal guard — calling it twice would install duplicate turbo:render / turbolinks:render listeners and double-fire callbacks on every navigation. A single boolean guard at the top of the function would eliminate this latent risk for any future callers.

3. Missing negative assertion for loading state (minor)

The loading-state test (~line 101) does not assert that readystatechange is not registered. This is intentional behaviour (no fallback needed when DOMContentLoaded has not fired yet), so an explicit expect(addEventListenerSpy).not.toHaveBeenCalledWith("readystatechange", ...) would document the invariant.

4. Missing multi-onPageLoaded call coverage for interactive state (minor)

The "should not initialize listeners multiple times" test (~line 241) only exercises readyState === "loading". With interactive state the first onPageLoaded call now registers two listeners; there is no test asserting the second call does not add more. Given the isPageLifecycleInitialized guard this works correctly, but explicit coverage would prevent a regression.


Changelog

Entry is clear and correctly formatted.

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.

Comment on lines +379 to +397
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));
});
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
});

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 — 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pageLifecycle misses startup during interactive if DOMContentLoaded already fired

1 participant