Initial visit restores a stale page object from history without checking the asset version
Summary
When a document is loaded through a browser back/forward traversal, InitialVisit.handleBackForward() takes the page object out of window.history.state and renders it without comparing its version against the current one. History entries survive browser restarts, so this object can be arbitrarily old — we have observed page objects serialized roughly eight months earlier being rendered against a current bundle.
handlePopstateEvent() guards against exactly this; handleBackForward() does not.
The asymmetry
handlePopstateEvent (src/eventHandler.ts):
history.decrypt(state.page).then((data) => {
if (page.get().version !== data.version) {
this.onMissingHistoryItem();
return;
}
...handleBackForward (src/initialVisit.ts):
const scrollRegions = history.getScrollRegions();
history.decrypt().then((data) => {
const visitId = uid();
page.set(data, { preserveScroll: true, preserveState: true, visitId }).then(() => {I checked dist/index.js of both 3.6.1 and 3.7.1: in both, page.get().version !== data.version appears exactly once, in the popstate handler. This is not a recent regression.
Impact
The restored page object carries the props that existed when it was written. Any prop added since is undefined, and the app renders against it with no indication that anything is wrong. In our case a layout component passed such a prop to a parser that throws on non-strings, so the render threw inside swapComponent's flushSync. Since the only error boundary was below that component, the new screen never committed and the navigation silently did nothing.
The failure is not tied to any route: it depends only on how old the visitor's history entry is.
Evidence
Telemetry from our production app, on every captured occurrence:
performance.getEntriesByType('navigation')[0].type === 'back_forward'— 100%- the restored page object had no top-level
sharedProps— 100%, although our server (inertia-rails,expose_shared_prop_keysenabled) always sends it - the props present stopped exactly at those introduced before a certain date, and every prop added after it was missing — consistent with an object serialized by a build from that time
document.visibilityState was mixed (hidden and visible), so this is not limited to background tabs.
Reproduction
- Serve an Inertia app whose adapter sets a non-empty asset
version. - Visit page A, then make a client-side visit to page B, so the history entry holds B's page object.
- Simulate an entry written by an older build:
const s = history.state; s.page.version = 'stale'; delete s.page.props.someProp; history.replaceState(s, '', location.href); - Navigate the tab to another origin so the document unloads, then press Back.
- On boot,
handleBackForward()restores the tampered object and renders it. No version check runs andsomePropisundefinedin the component.
Expected: the stale entry is discarded and the current URL is re-fetched, as it is on the popstate path.
Suggested fix
const scrollRegions = history.getScrollRegions();
history.decrypt().then((data) => {
+ if (page.get().version !== data.version) {
+ eventHandler.onMissingHistoryItem();
+ return;
+ }
const visitId = uid();
page.set(data, { preserveScroll: true, preserveState: true, visitId }).then(() => {At that point page already holds the page object from the freshly served document, so the comparison is against the current version. The recovery path is already wired: the missingHistoryItem listener re-visits window.location.href with preserveState and preserveScroll, which yields a correct page at the cost of one request.
Production confirmation
We shipped exactly this change as a local patch. Over the following 17 hours the patched release carried live traffic — unrelated frontend errors kept arriving from it, the most recent one minute before this was written — and produced zero occurrences of this bug, against a pre-patch rate of roughly 2.4 per hour. Occurrences continue from older bundles still running in long-lived tabs, which the patch cannot reach.
Versions
@inertiajs/core 3.7.0 and 3.7.1, @inertiajs/react 3.7.x, React 19. Server adapter: inertia-rails 3.22.0.
Source: inertiajs/inertia