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
8 changes: 6 additions & 2 deletions packages/core/src/request.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { default as axios, AxiosProgressEvent, AxiosRequestConfig } from 'axios'
import { type AxiosProgressEvent, type AxiosRequestConfig, default as axios } from 'axios'
import { fireExceptionEvent, fireFinishEvent, firePrefetchingEvent, fireProgressEvent, fireStartEvent } from './events'
import { page as currentPage } from './page'
import { RequestParams } from './requestParams'
import { Response } from './response'
import { ActiveVisit, Page } from './types'
import type { ActiveVisit, Page } from './types'
import { urlWithoutHash } from './url'

export class Request {
Expand All @@ -24,6 +24,10 @@ export class Request {
return new Request(params, page)
}

public isPrefetch(): boolean {
return this.requestParams.all().prefetch ?? false
}

public async send() {
this.requestParams.onCancelToken(() => this.cancel({ cancelled: true }))

Expand Down
19 changes: 18 additions & 1 deletion packages/core/src/requestStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,24 @@ export class RequestStream {
}

public cancelInFlight(): void {
this.cancel({ cancelled: true }, true)
// Cancel ALL in-flight requests (used for async stream with unlimited concurrency)
// Note: We don't clear this.requests = [] because cancelled requests will remove
// themselves via the filter in send() when their promise resolves
const requestsToCancel = [...this.requests]
requestsToCancel.forEach((request) => {
request.cancel({ cancelled: true })
})
}

public cancelNonPrefetchInFlight(): void {
// Cancel only non-prefetch requests (deferred props, regular visits)
// Prefetch requests populate the cache and are safe to continue even after navigation
// Note: We don't clear this.requests = [] because cancelled requests will remove
// themselves via the filter in send() when their promise resolves
const requestsToCancel = [...this.requests].filter((request) => !request.isPrefetch())
requestsToCancel.forEach((request) => {
request.cancel({ cancelled: true })
})
}

protected cancel({ cancelled = false, interrupted = false } = {}, force: boolean): void {
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
VisitHelperOptions,
VisitOptions,
} from './types'
import { isUrlMethodPair, transformUrlAndData } from './url'
import { hrefToUrl, isSameUrlWithoutHash, isUrlMethodPair, transformUrlAndData } from './url'

export class Router {
protected syncRequestStream = new RequestStream({
Expand Down Expand Up @@ -181,6 +181,17 @@ export class Router {
return
}

// Cancel in-flight async (deferred) requests when navigating to a different URL
// This prevents stale deferred prop data from appearing after rapid navigation
// We only cancel if the URL is changing - stays on same page (reloads, partial updates) are allowed
const isSameUrl = !currentPage.isCleared() && isSameUrlWithoutHash(visit.url, hrefToUrl(currentPage.get().url))

if (!isSameUrl) {
// Only cancel non-prefetch requests (deferred props)
// Prefetch requests populate cache and are safe to continue
this.asyncRequestStream.cancelNonPrefetchInFlight()
}

const requestStream = visit.async ? this.asyncRequestStream : this.syncRequestStream

requestStream.interruptInFlight()
Expand Down
80 changes: 80 additions & 0 deletions packages/react/test-app/Pages/DeferredProps/RapidNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Deferred, Link, router, usePage } from '@inertiajs/react'

const Users = () => {
const { users } = usePage<{ users?: { text: string } }>().props
return <div>{users?.text}</div>
}

const Stats = () => {
const { stats } = usePage<{ stats?: { text: string } }>().props
return <div>{stats?.text}</div>
}

const Activity = () => {
const { activity } = usePage<{ activity?: { text: string } }>().props
return <div>{activity?.text}</div>
}

export default () => {
const { filter } = usePage<{ filter: string }>().props

return (
<>
<div>Current filter: {filter}</div>

<Deferred data="users" fallback={<div>Loading users...</div>}>
<Users />
</Deferred>

<Deferred data="stats" fallback={<div>Loading stats...</div>}>
<Stats />
</Deferred>

<Deferred data="activity" fallback={<div>Loading activity...</div>}>
<Activity />
</Deferred>

<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
<Link href="/deferred-props/page-1">Navigate Away</Link>

<button
onClick={() => {
const shouldNavigate = confirm('Navigate away?')
if (shouldNavigate) {
router.visit('/deferred-props/page-2')
}
}}
>
Navigate with onBefore
</button>

<button onClick={() => router.reload({ except: ['stats'] })}>Reload with except</button>

<button onClick={() => router.visit('/deferred-props/rapid-navigation/b', { only: ['users'] })}>
Visit B with only
</button>

<button onClick={() => router.visit(`/deferred-props/rapid-navigation/${filter}`)}>Re-visit same URL</button>

<button onClick={() => router.reload()}>Plain reload</button>

<button onClick={() => router.reload({ only: ['users'], except: ['stats'] })}>Reload with only and except</button>

<button onClick={() => router.visit('/deferred-props/rapid-navigation/b', { except: ['stats'] })}>
Visit B with except
</button>

<button
onClick={() => router.visit('/deferred-props/rapid-navigation/b', { only: ['users'], except: ['stats'] })}
>
Visit B with only and except
</button>

<button onClick={() => router.prefetch('/deferred-props/rapid-navigation/b')}>Prefetch Filter B</button>

<button onClick={() => router.prefetch('/deferred-props/page-1')}>Prefetch Page 1</button>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script lang="ts">
import { Deferred, Link, router, page } from '@inertiajs/svelte'

$: filter = $page.props.filter as string
$: users = $page.props.users as { text: string } | undefined
$: stats = $page.props.stats as { text: string } | undefined
$: activity = $page.props.activity as { text: string } | undefined

function handleOnBeforeClick() {
const shouldNavigate = confirm('Navigate away?')
if (shouldNavigate) {
router.visit('/deferred-props/page-2')
}
}
</script>

<div>Current filter: {filter}</div>

<Deferred data="users">
<div slot="fallback">Loading users...</div>
<div>{users?.text}</div>
</Deferred>

<Deferred data="stats">
<div slot="fallback">Loading stats...</div>
<div>{stats?.text}</div>
</Deferred>

<Deferred data="activity">
<div slot="fallback">Loading activity...</div>
<div>{activity?.text}</div>
</Deferred>

<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
<Link href="/deferred-props/page-1">Navigate Away</Link>

<button on:click={handleOnBeforeClick}>Navigate with onBefore</button>

<button on:click={() => router.reload({ except: ['stats'] })}>Reload with except</button>

<button on:click={() => router.visit('/deferred-props/rapid-navigation/b', { only: ['users'] })}>
Visit B with only
</button>

<button on:click={() => router.visit(`/deferred-props/rapid-navigation/${filter}`)}> Re-visit same URL </button>

<button on:click={() => router.reload()}>Plain reload</button>

<button on:click={() => router.reload({ only: ['users'], except: ['stats'] })}> Reload with only and except </button>

<button on:click={() => router.visit('/deferred-props/rapid-navigation/b', { except: ['stats'] })}>
Visit B with except
</button>

<button on:click={() => router.visit('/deferred-props/rapid-navigation/b', { only: ['users'], except: ['stats'] })}>
Visit B with only and except
</button>

<button on:click={() => router.prefetch('/deferred-props/rapid-navigation/b')}> Prefetch Filter B </button>

<button on:click={() => router.prefetch('/deferred-props/page-1')}> Prefetch Page 1 </button>
71 changes: 71 additions & 0 deletions packages/vue3/test-app/Pages/DeferredProps/RapidNavigation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { Deferred, Link, router } from '@inertiajs/vue3'

defineProps<{
filter: string
users?: { text: string }
stats?: { text: string }
activity?: { text: string }
}>()

const handleOnBeforeClick = () => {
const shouldNavigate = confirm('Navigate away?')
if (shouldNavigate) {
router.visit('/deferred-props/page-2')
}
}
</script>

<template>
<div>Current filter: {{ filter }}</div>

<Deferred data="users">
<div>{{ users?.text }}</div>
<template #fallback>
<div>Loading users...</div>
</template>
</Deferred>

<Deferred data="stats">
<div>{{ stats?.text }}</div>
<template #fallback>
<div>Loading stats...</div>
</template>
</Deferred>

<Deferred data="activity">
<div>{{ activity?.text }}</div>
<template #fallback>
<div>Loading activity...</div>
</template>
</Deferred>

<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
<Link href="/deferred-props/page-1">Navigate Away</Link>

<button @click="handleOnBeforeClick">Navigate with onBefore</button>

<button @click="router.reload({ except: ['stats'] })">Reload with except</button>

<button @click="router.visit('/deferred-props/rapid-navigation/b', { only: ['users'] })">Visit B with only</button>

<button @click="router.visit(`/deferred-props/rapid-navigation/${filter}`)">Re-visit same URL</button>

<button @click="router.reload()">Plain reload</button>

<button @click="router.reload({ only: ['users'], except: ['stats'] })">Reload with only and except</button>

<button @click="router.visit('/deferred-props/rapid-navigation/b', { except: ['stats'] })">
Visit B with except
</button>

<button @click="router.visit('/deferred-props/rapid-navigation/b', { only: ['users'], except: ['stats'] })">
Visit B with only and except
</button>

<button @click="router.prefetch('/deferred-props/rapid-navigation/b')">Prefetch Filter B</button>

<button @click="router.prefetch('/deferred-props/page-1')">Prefetch Page 1</button>
</template>
34 changes: 34 additions & 0 deletions tests/app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,40 @@ app.get('/deferred-props/instant-reload', (req, res) => {
)
})

app.get('/deferred-props/rapid-navigation/:filter?', (req, res) => {
const filter = req.params.filter || 'none'
const requestedProps = req.headers['x-inertia-partial-data']

if (!requestedProps) {
return inertia.render(req, res, {
component: 'DeferredProps/RapidNavigation',
deferredProps: {
group1: ['users'],
group2: ['stats'],
group3: ['activity'],
},
props: {
filter,
},
})
}

// Simulate slow deferred prop loading (600ms)
setTimeout(
() =>
inertia.render(req, res, {
component: 'DeferredProps/RapidNavigation',
props: {
filter,
users: requestedProps.includes('users') ? { text: `users data for ${filter}` } : undefined,
stats: requestedProps.includes('stats') ? { text: `stats data for ${filter}` } : undefined,
activity: requestedProps.includes('activity') ? { text: `activity data for ${filter}` } : undefined,
},
}),
600,
)
})

app.get('/svelte/props-and-page-store', (req, res) =>
inertia.render(req, res, { component: 'Svelte/PropsAndPageStore', props: { foo: req.query.foo || 'default' } }),
)
Expand Down
Loading