InitialVisit.handleBackForward restores a page from history without comparing asset versions, so a re-fetched document renders the previous deploy's props
@inertiajs/react Version
3.7.1 (confirmed present in packages/core/src/initialVisit.ts on the latest published release)
Backend stack (optional)
Rails + inertia_rails 3.22.0, encrypt_history = true, asset version set to the Vite build digest
Describe the problem
Asset versioning is Inertia's documented answer to a client holding props from an older deploy: the
server compares X-Inertia-Version and, when it differs, replies 409 with X-Inertia-Location so
the client does a hard visit. That handshake only runs when a request is made.
InitialVisit.handleBackForward() renders a page decrypted straight out of history.state without
making one, and it does not perform the local equivalent either — unlike its sibling
handlePopstateEvent, which restores from the same source and does compare:
// packages/core/src/eventHandler.ts — handlePopstateEvent
history.decrypt(state.page).then((data) => {
if (currentPage.get().version !== data.version) {
this.onMissingHistoryItem()
return
}
...
})
// packages/core/src/initialVisit.ts — handleBackForward
history.decrypt().then((data) => {
const visitId = uid()
currentPage.set(data, { preserveScroll: true, preserveState: true, visitId }) // no comparison
})So when the browser re-fetches the document on a back/forward navigation, the page runs the newly
deployed bundle against props the previous deploy serialized. Any prop key added or renamed since
reads undefined, and a component that dereferences it throws. Rendering an app-wide error boundary
turns a single missing key into a dead page.
This is not a regression from #3196. Before that fix the React adapter discarded the restored page, so the stale props never reached a render and the gap was largely invisible in React apps. #3196 correctly made the restore render — which is what makes the missing version comparison reachable.
Two deploys in our production app landed a renamed serializer key and a new config key. Each produced a crash within minutes, and one has affected 33 users across 11 releases. In every case the Sentry release recorded on the event — baked into the bundle at build time — contains the commit that introduced the key, so the bundle is always current and only the props are stale.
Steps to reproduce
- Serve an app with asset versioning configured, so
page.versionchanges between builds. - Load a page, then navigate away so the first page is in history with its props stored.
- Deploy a build that adds a prop key and reads it unguarded in that page's component.
- Press Back, with the document re-fetched rather than served from bfcache or the HTTP cache. A
bfcache restore never re-runs scripts and so never reaches this code; a cached document carries
the old bundle alongside the old props and is self-consistent. Forcing
Cache-Control: no-storeon the document is the most reliable way to exercise it. handleBackForward()restores the stored page and the component readsundefinedon the new key.
Proposed Solution
Have handleBackForward() fall through to handleDefault() when the stored page's version does not
match the current one, so the freshly fetched document renders instead of the snapshot.
Copying the handlePopstateEvent guard verbatim will not work: calling onMissingHistoryItem() and
returning true stops scenarios.find(), and since nothing is mounted yet at InitialVisit time
the result is that nothing renders at all. handlePopstateEvent can bail that way only because the
app is already on screen.
The version also sits behind an async decrypt(), while handleBackForward() returns a boolean
synchronously, so the fall-through has to be invoked from inside the .then() rather than by
returning false.
Happy to open a PR if the approach looks right.
Source: inertiajs/inertia