Skip to content

Commit aecd016

Browse files
committed
fix: cancel in-flight deferred prop requests on navigation
When users rapidly navigate between pages with deferred props, multiple requests fire in quick succession. Previously, deferred prop requests from previous navigations were not cancelled, causing them to complete out-of-order and display stale data. This change cancels all in-flight async (deferred) requests when starting a new main visit, while preserving concurrent loading of deferred props within the same page load. Changes: - Fix RequestStream.cancelInFlight() to cancel ALL requests (not just first) - Add cancellation logic in Router.visit() after onBefore check - Add detection to distinguish deferred requests from main navigation - Add comprehensive test suite for cancellation behavior The cancellation happens after onBefore checks to avoid cancelling deferred props when navigation is prevented by user confirmation dialogs.
1 parent 13e8981 commit aecd016

File tree

7 files changed

+459
-1
lines changed

7 files changed

+459
-1
lines changed

packages/core/src/requestStream.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ export class RequestStream {
2525
}
2626

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

3137
protected cancel({ cancelled = false, interrupted = false } = {}, force: boolean): void {

packages/core/src/router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ export class Router {
181181
return
182182
}
183183

184+
// Cancel in-flight async (deferred) requests when starting a new main visit
185+
// This prevents stale deferred prop data from appearing after rapid navigation
186+
const isDeferredRequest =
187+
visit.only && Array.isArray(visit.only) && visit.only.length > 0 && visit.except.length === 0
188+
189+
if (!isDeferredRequest) {
190+
this.asyncRequestStream.cancelInFlight()
191+
}
192+
184193
const requestStream = visit.async ? this.asyncRequestStream : this.syncRequestStream
185194

186195
requestStream.interruptInFlight()
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Deferred, Link, router, usePage } from '@inertiajs/react'
2+
3+
const Users = () => {
4+
const { users } = usePage<{ users?: { text: string } }>().props
5+
return <div>{users?.text}</div>
6+
}
7+
8+
const Stats = () => {
9+
const { stats } = usePage<{ stats?: { text: string } }>().props
10+
return <div>{stats?.text}</div>
11+
}
12+
13+
const Activity = () => {
14+
const { activity } = usePage<{ activity?: { text: string } }>().props
15+
return <div>{activity?.text}</div>
16+
}
17+
18+
export default () => {
19+
const { filter } = usePage<{ filter: string }>().props
20+
21+
return (
22+
<>
23+
<div>Current filter: {filter}</div>
24+
25+
<Deferred data="users" fallback={<div>Loading users...</div>}>
26+
<Users />
27+
</Deferred>
28+
29+
<Deferred data="stats" fallback={<div>Loading stats...</div>}>
30+
<Stats />
31+
</Deferred>
32+
33+
<Deferred data="activity" fallback={<div>Loading activity...</div>}>
34+
<Activity />
35+
</Deferred>
36+
37+
<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
38+
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
39+
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
40+
<Link href="/deferred-props/page-1">Navigate Away</Link>
41+
42+
<button
43+
onClick={() => {
44+
const shouldNavigate = confirm('Navigate away?')
45+
if (shouldNavigate) {
46+
router.visit('/deferred-props/page-2')
47+
}
48+
}}
49+
>
50+
Navigate with onBefore
51+
</button>
52+
</>
53+
)
54+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script lang="ts">
2+
import { Deferred, Link, router, page } from '@inertiajs/svelte'
3+
4+
$: filter = $page.props.filter as string
5+
$: users = $page.props.users as { text: string } | undefined
6+
$: stats = $page.props.stats as { text: string } | undefined
7+
$: activity = $page.props.activity as { text: string } | undefined
8+
9+
function handleOnBeforeClick() {
10+
const shouldNavigate = confirm('Navigate away?')
11+
if (shouldNavigate) {
12+
router.visit('/deferred-props/page-2')
13+
}
14+
}
15+
</script>
16+
17+
<div>Current filter: {filter}</div>
18+
19+
<Deferred data="users">
20+
<div slot="fallback">Loading users...</div>
21+
<div>{users?.text}</div>
22+
</Deferred>
23+
24+
<Deferred data="stats">
25+
<div slot="fallback">Loading stats...</div>
26+
<div>{stats?.text}</div>
27+
</Deferred>
28+
29+
<Deferred data="activity">
30+
<div slot="fallback">Loading activity...</div>
31+
<div>{activity?.text}</div>
32+
</Deferred>
33+
34+
<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
35+
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
36+
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
37+
<Link href="/deferred-props/page-1">Navigate Away</Link>
38+
39+
<button on:click={handleOnBeforeClick}>Navigate with onBefore</button>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
import { Deferred, Link, router } from '@inertiajs/vue3'
3+
4+
defineProps<{
5+
filter: string
6+
users?: { text: string }
7+
stats?: { text: string }
8+
activity?: { text: string }
9+
}>()
10+
11+
const handleOnBeforeClick = () => {
12+
const shouldNavigate = confirm('Navigate away?')
13+
if (shouldNavigate) {
14+
router.visit('/deferred-props/page-2')
15+
}
16+
}
17+
</script>
18+
19+
<template>
20+
<div>Current filter: {{ filter }}</div>
21+
22+
<Deferred data="users">
23+
<div>{{ users?.text }}</div>
24+
<template #fallback>
25+
<div>Loading users...</div>
26+
</template>
27+
</Deferred>
28+
29+
<Deferred data="stats">
30+
<div>{{ stats?.text }}</div>
31+
<template #fallback>
32+
<div>Loading stats...</div>
33+
</template>
34+
</Deferred>
35+
36+
<Deferred data="activity">
37+
<div>{{ activity?.text }}</div>
38+
<template #fallback>
39+
<div>Loading activity...</div>
40+
</template>
41+
</Deferred>
42+
43+
<Link href="/deferred-props/rapid-navigation/a">Filter A</Link>
44+
<Link href="/deferred-props/rapid-navigation/b">Filter B</Link>
45+
<Link href="/deferred-props/rapid-navigation/c">Filter C</Link>
46+
<Link href="/deferred-props/page-1">Navigate Away</Link>
47+
48+
<button @click="handleOnBeforeClick">Navigate with onBefore</button>
49+
</template>

tests/app/server.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,40 @@ app.get('/deferred-props/instant-reload', (req, res) => {
733733
)
734734
})
735735

736+
app.get('/deferred-props/rapid-navigation/:filter?', (req, res) => {
737+
const filter = req.params.filter || 'none'
738+
const requestedProps = req.headers['x-inertia-partial-data']
739+
740+
if (!requestedProps) {
741+
return inertia.render(req, res, {
742+
component: 'DeferredProps/RapidNavigation',
743+
deferredProps: {
744+
group1: ['users'],
745+
group2: ['stats'],
746+
group3: ['activity'],
747+
},
748+
props: {
749+
filter,
750+
},
751+
})
752+
}
753+
754+
// Simulate slow deferred prop loading (600ms)
755+
setTimeout(
756+
() =>
757+
inertia.render(req, res, {
758+
component: 'DeferredProps/RapidNavigation',
759+
props: {
760+
filter,
761+
users: requestedProps.includes('users') ? { text: `users data for ${filter}` } : undefined,
762+
stats: requestedProps.includes('stats') ? { text: `stats data for ${filter}` } : undefined,
763+
activity: requestedProps.includes('activity') ? { text: `activity data for ${filter}` } : undefined,
764+
},
765+
}),
766+
600,
767+
)
768+
})
769+
736770
app.get('/svelte/props-and-page-store', (req, res) =>
737771
inertia.render(req, res, { component: 'Svelte/PropsAndPageStore', props: { foo: req.query.foo || 'default' } }),
738772
)

0 commit comments

Comments
 (0)