@inertiajs/react Version
2.3.24 (confirmed the same code path is unchanged in the latest published 3.6.1 — packages/react/src/App.ts and packages/core/src/initialVisit.ts)
Backend stack (optional)
Rails + inertia_rails (3.19.0), using InertiaRails.defer(group: ...)
Describe the problem
@inertiajs/react's App.ts unconditionally discards the first swapComponent call after a fresh module load, to avoid re-rendering the SSR-embedded initial page a second time on a normal load (added in #2377 / #2381 / #2383):
// packages/react/src/App.ts
let currentIsInitialPage = true
let swapComponent: PageHandler<ReactComponent> = async () => {
currentIsInitialPage = false
}
useEffect(() => {
swapComponent = async ({ component, page, preserveState }) => {
if (currentIsInitialPage) {
currentIsInitialPage = false
return // restored, up-to-date page is discarded here
}
flushSync(() => setCurrent(...))
}
router.on('navigate', () => headManager.forceUpdate())
}, [])
That assumption — "the first swap after module load is always redundant with the initial page" — is true for InitialVisit.handleDefault() (the swapped-in page is the same object used to seed initialPage), but false for InitialVisit.handleBackForward() in packages/core/src/initialVisit.ts. On a back_forward-type navigation (this is how Chrome and Firefox report tab duplication via the Navigation Timing API), handleBackForward() decrypts the browser's stored history state and swaps in that restored page — which can legitimately differ from the SSR-embedded initialPage, e.g. it may already contain deferred props that had resolved in the original tab before it was duplicated.
Because this is still the first swap since module load, React's guard silently drops it. currentIsInitialPage is module-scoped, so it only protects against the very first swap — after this drop, all later swaps work correctly, but the damage is done: usePage().props stays stuck on the stale SSR snapshot even though Inertia's own internal page state (and event.detail.page on the navigate event) already has the correct, restored data. If a page's primary content depends on the dropped deferred props (not just a secondary widget), the app appears permanently stuck loading, with no further event to recover from it.
This is distinct from #2812 ("Fix deferred props not loading after back button navigation"), which patches handlePopstateEvent in packages/core/src/eventHandler.ts for mid-session back-button navigation (after the app has already mounted). It does not touch InitialVisit.handleBackForward(), which only runs at module/mount time and isn't covered by that fix.
Steps to reproduce
- Use a page with deferred props (Inertia::defer() / InertiaRails.defer()), and let the deferred group load and resolve during normal use (props now present in Inertia's page/history state).
- Duplicate the browser tab in Chrome or Firefox (e.g. right-click the tab → Duplicate).
- The new tab's navigation is reported as back_forward (performance.getEntriesByType('navigation')[0].type).
- InitialVisit.handleBackForward() restores the decrypted history page — which has the deferred props already resolved — and swaps, but React drops this swap since it's the first one since module load.
- usePage().props in the new tab never receives the deferred props; any UI gated on them stays in a loading state indefinitely.
Proposed Solution
The core assumption in #2377 — "the first swap after module load is always redundant" — only holds for InitialVisit.handleDefault(), not InitialVisit.handleBackForward(). Two possible directions:
Have InitialVisit.handleBackForward() (and the equivalent path in handlePopstateEvent, if applicable) signal to the adapter that this particular swap should not be treated as the "redundant first swap" — e.g. a flag carried on the swap payload.
In the React adapter, only skip the first swap when the incoming page is reference/content-equal to initialPage, rather than unconditionally skipping by call order regardless of content.
@inertiajs/react Version
2.3.24 (confirmed the same code path is unchanged in the latest published 3.6.1 — packages/react/src/App.ts and packages/core/src/initialVisit.ts)
Backend stack (optional)
Rails + inertia_rails (3.19.0), using InertiaRails.defer(group: ...)
Describe the problem
@inertiajs/react's App.ts unconditionally discards the first swapComponent call after a fresh module load, to avoid re-rendering the SSR-embedded initial page a second time on a normal load (added in #2377 / #2381 / #2383):
That assumption — "the first swap after module load is always redundant with the initial page" — is true for InitialVisit.handleDefault() (the swapped-in page is the same object used to seed initialPage), but false for InitialVisit.handleBackForward() in
packages/core/src/initialVisit.ts. On a back_forward-type navigation (this is how Chrome and Firefox report tab duplication via the Navigation Timing API),handleBackForward()decrypts the browser's stored history state and swaps in that restored page — which can legitimately differ from the SSR-embedded initialPage, e.g. it may already contain deferred props that had resolved in the original tab before it was duplicated.Because this is still the first swap since module load, React's guard silently drops it. currentIsInitialPage is module-scoped, so it only protects against the very first swap — after this drop, all later swaps work correctly, but the damage is done: usePage().props stays stuck on the stale SSR snapshot even though Inertia's own internal page state (and event.detail.page on the navigate event) already has the correct, restored data. If a page's primary content depends on the dropped deferred props (not just a secondary widget), the app appears permanently stuck loading, with no further event to recover from it.
This is distinct from #2812 ("Fix deferred props not loading after back button navigation"), which patches
handlePopstateEventinpackages/core/src/eventHandler.tsfor mid-session back-button navigation (after the app has already mounted). It does not touchInitialVisit.handleBackForward(), which only runs at module/mount time and isn't covered by that fix.Steps to reproduce
Proposed Solution
The core assumption in #2377 — "the first swap after module load is always redundant" — only holds for InitialVisit.handleDefault(), not InitialVisit.handleBackForward(). Two possible directions:
Have InitialVisit.handleBackForward() (and the equivalent path in handlePopstateEvent, if applicable) signal to the adapter that this particular swap should not be treated as the "redundant first swap" — e.g. a flag carried on the swap payload.
In the React adapter, only skip the first swap when the incoming page is reference/content-equal to initialPage, rather than unconditionally skipping by call order regardless of content.