diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts index f062c7976..c3683714c 100644 --- a/packages/core/src/request.ts +++ b/packages/core/src/request.ts @@ -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 { @@ -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 })) diff --git a/packages/core/src/requestStream.ts b/packages/core/src/requestStream.ts index 46ebfc1a7..484b7a927 100644 --- a/packages/core/src/requestStream.ts +++ b/packages/core/src/requestStream.ts @@ -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 { diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index 86751701b..0ef3373c1 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -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({ @@ -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() diff --git a/packages/react/test-app/Pages/DeferredProps/RapidNavigation.tsx b/packages/react/test-app/Pages/DeferredProps/RapidNavigation.tsx new file mode 100644 index 000000000..f8d7a5a77 --- /dev/null +++ b/packages/react/test-app/Pages/DeferredProps/RapidNavigation.tsx @@ -0,0 +1,80 @@ +import { Deferred, Link, router, usePage } from '@inertiajs/react' + +const Users = () => { + const { users } = usePage<{ users?: { text: string } }>().props + return
{users?.text}
+} + +const Stats = () => { + const { stats } = usePage<{ stats?: { text: string } }>().props + return
{stats?.text}
+} + +const Activity = () => { + const { activity } = usePage<{ activity?: { text: string } }>().props + return
{activity?.text}
+} + +export default () => { + const { filter } = usePage<{ filter: string }>().props + + return ( + <> +
Current filter: {filter}
+ + Loading users...}> + + + + Loading stats...}> + + + + Loading activity...}> + + + + Filter A + Filter B + Filter C + Navigate Away + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/svelte/test-app/Pages/DeferredProps/RapidNavigation.svelte b/packages/svelte/test-app/Pages/DeferredProps/RapidNavigation.svelte new file mode 100644 index 000000000..9e7902fb5 --- /dev/null +++ b/packages/svelte/test-app/Pages/DeferredProps/RapidNavigation.svelte @@ -0,0 +1,63 @@ + + +
Current filter: {filter}
+ + +
Loading users...
+
{users?.text}
+
+ + +
Loading stats...
+
{stats?.text}
+
+ + +
Loading activity...
+
{activity?.text}
+
+ +Filter A +Filter B +Filter C +Navigate Away + + + + + + + + + + + + + + + + + + + + diff --git a/packages/vue3/test-app/Pages/DeferredProps/RapidNavigation.vue b/packages/vue3/test-app/Pages/DeferredProps/RapidNavigation.vue new file mode 100644 index 000000000..bf14dd7c4 --- /dev/null +++ b/packages/vue3/test-app/Pages/DeferredProps/RapidNavigation.vue @@ -0,0 +1,71 @@ + + + diff --git a/tests/app/server.js b/tests/app/server.js index 4926db551..fa997ccad 100644 --- a/tests/app/server.js +++ b/tests/app/server.js @@ -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' } }), ) diff --git a/tests/deferred-props-cancellation.spec.ts b/tests/deferred-props-cancellation.spec.ts new file mode 100644 index 000000000..c9aba0e30 --- /dev/null +++ b/tests/deferred-props-cancellation.spec.ts @@ -0,0 +1,992 @@ +import { expect, test } from '@playwright/test' +import { clickAndWaitForResponse } from './support' + +test.describe('Deferred Props Cancellation', () => { + test('initial page load completes all deferred props without cancellation', async ({ page }) => { + const cancelledRequests: string[] = [] + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation') + + // Wait for all loading states to appear + await expect(page.getByText('Loading users...')).toBeVisible() + await expect(page.getByText('Loading stats...')).toBeVisible() + await expect(page.getByText('Loading activity...')).toBeVisible() + + // Wait for all 3 deferred prop responses (3 separate groups) + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify all data loaded + await expect(page.getByText('users data for none')).toBeVisible() + await expect(page.getByText('stats data for none')).toBeVisible() + await expect(page.getByText('activity data for none')).toBeVisible() + + // Verify no requests were cancelled + expect(cancelledRequests).toHaveLength(0) + }) + + test('rapid navigation cancels in-flight deferred props', async ({ page }) => { + // Track which deferred prop requests were cancelled + const cancelledRequests: string[] = [] + const completedRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + page.on('response', (response) => { + if ( + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] && + response.status() === 200 + ) { + completedRequests.push(response.request().headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/a') + + // Wait for initial page load to complete + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Wait for loading states to appear (deferred props have started) + await expect(page.getByText('Loading users...')).toBeVisible() + + // Immediately navigate to filter=b (before filter=a deferred props complete - they take 600ms) + await page.getByRole('link', { name: 'Filter B' }).click() + + // Wait for filter B's page to load + await expect(page.getByText('Current filter: b')).toBeVisible() + + // Wait for all 3 of filter B's deferred props to complete + await page.waitForResponse( + (response) => + response.url().endsWith('/b') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/b') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/b') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify filter B data is visible + await expect(page.getByText('users data for b')).toBeVisible() + await expect(page.getByText('stats data for b')).toBeVisible() + await expect(page.getByText('activity data for b')).toBeVisible() + + // Filter A's 3 deferred requests were CANCELLED (never completed) + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + + // Verify only filter B's requests completed (not filter A's) + expect(completedRequests).toEqual(['users', 'stats', 'activity']) + }) + + test('multiple rapid clicks only complete latest request', async ({ page }) => { + const cancelledRequests: string[] = [] + const completedRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + page.on('response', (response) => { + if ( + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] && + response.status() === 200 + ) { + completedRequests.push(response.request().headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/none') + + // Wait for initial page load + await expect(page.getByText('Current filter: none')).toBeVisible() + + // Rapid fire clicks (all before any deferred props complete) + await page.getByRole('link', { name: 'Filter A' }).click() + await page.getByRole('link', { name: 'Filter B' }).click() + await page.getByRole('link', { name: 'Filter C' }).click() + + // Wait for filter C to complete + await expect(page.getByText('Current filter: c')).toBeVisible() + + // Wait for all 3 of filter C's deferred props + await page.waitForResponse( + (response) => + response.url().endsWith('/c') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/c') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/c') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Only filter C data should be visible + await expect(page.getByText('users data for c')).toBeVisible() + await expect(page.getByText('stats data for c')).toBeVisible() + await expect(page.getByText('activity data for c')).toBeVisible() + + // All deferred requests from none, A, and B were cancelled + // 3 from initial (none) + 3 from A + 3 from B = 9 cancelled requests + expect(cancelledRequests).toHaveLength(9) + + // Only filter C's requests should have completed + expect(completedRequests).toEqual(['users', 'stats', 'activity']) + }) + + test('navigation to different page cancels all in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation') + + // Wait for loading states + await expect(page.getByText('Loading users...')).toBeVisible() + + // Navigate to a completely different page + await clickAndWaitForResponse(page, 'Navigate Away', '/deferred-props/page-1') + + // We should now be on page-1 + await expect(page.getByText('Loading foo...')).toBeVisible() + + // Wait for page-1 deferred props + await page.waitForResponse( + (response) => + response.url().includes('page-1') && + response.request().headers()['x-inertia-partial-data'] && + response.status() === 200, + ) + + // Verify page-1 data loaded + await expect(page.getByText('foo value')).toBeVisible() + + // All 3 of rapid-navigation's deferred requests were cancelled + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('deferred prop groups load concurrently without cancelling each other', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation') + + // All loading states should appear simultaneously + await expect(page.getByText('Loading users...')).toBeVisible() + await expect(page.getByText('Loading stats...')).toBeVisible() + await expect(page.getByText('Loading activity...')).toBeVisible() + + // Wait for all 3 deferred prop groups to complete concurrently + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // All should complete successfully + await expect(page.getByText('users data for none')).toBeVisible() + await expect(page.getByText('stats data for none')).toBeVisible() + await expect(page.getByText('activity data for none')).toBeVisible() + + // No deferred requests were cancelled + expect(cancelledRequests).toHaveLength(0) + }) + + test('onBefore preventing navigation does not cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/a') + + // Wait for page load + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Set up dialog handler to cancel the navigation + page.on('dialog', async (dialog) => { + expect(dialog.message()).toBe('Navigate away?') + await dialog.dismiss() // Click "Cancel" in the confirm dialog + }) + + // Try to navigate (will be prevented by onBefore) + await page.getByRole('button', { name: 'Navigate with onBefore' }).click() + + // Wait for all 3 original deferred props to complete (not cancelled) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // We should still be on filter=a + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Original deferred props should complete successfully (not cancelled) + await expect(page.getByText('users data for a')).toBeVisible() + await expect(page.getByText('stats data for a')).toBeVisible() + await expect(page.getByText('activity data for a')).toBeVisible() + + // No requests were cancelled (navigation was prevented) + expect(cancelledRequests).toHaveLength(0) + }) + + test('onBefore allowing navigation DOES cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/a') + + // Wait for page load + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Set up dialog handler to allow the navigation + page.on('dialog', async (dialog) => { + await dialog.accept() // Click "OK" in the confirm dialog + }) + + // Navigate (will be allowed by onBefore) + await page.getByRole('button', { name: 'Navigate with onBefore' }).click() + + // Wait for navigation to page-2 + await expect(page.getByText('Loading baz...')).toBeVisible() + + // Wait for page-2 deferred props + await page.waitForResponse( + (response) => + response.url().includes('page-2') && + response.request().headers()['x-inertia-partial-data'] && + response.status() === 200, + ) + + // Page-2 data should load + await expect(page.getByText('baz value')).toBeVisible() + + // All 3 of filter A's deferred requests were cancelled + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('partial reload with except on same page does NOT cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/a') + + // Wait for page load + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Immediately do a partial reload with except (same URL - should NOT cancel in-flight deferred props) + await page.getByRole('button', { name: 'Reload with except' }).click() + + // Wait for all 3 of the original deferred props to complete (not cancelled by reload) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify data is visible + await expect(page.getByText('users data for a')).toBeVisible() + await expect(page.getByText('stats data for a')).toBeVisible() + await expect(page.getByText('activity data for a')).toBeVisible() + + // No deferred requests were cancelled (same URL) + expect(cancelledRequests).toHaveLength(0) + }) + + test('navigate to different URL with only parameter DOES cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + await page.goto('/deferred-props/rapid-navigation/a') + + // Wait for page load + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Navigate to a DIFFERENT URL with only parameter + // Note: only causes partial merge, so filter prop won't update (same component) + // But cancellation should still happen because URL changed + await page.getByRole('button', { name: 'Visit B with only', exact: true }).click() + + // Wait for the partial update to complete (users prop from B) + await page.waitForResponse( + (response) => + response.url().endsWith('/b') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + + // Note: filter still shows 'a' because only: ['users'] means filter prop isn't merged + // But users data should be from B + await expect(page.getByText('users data for b')).toBeVisible() + + // Filter A's deferred requests (stats, activity) were cancelled (URL changed) + // Note: users from A was also cancelled, then users from B was requested + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('back navigation cancels in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A and wait for it to complete + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await expect(page.getByText('users data for a')).toBeVisible() + + // Navigate to filter B + await page.getByRole('link', { name: 'Filter B' }).click() + await expect(page.getByText('Current filter: b')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Immediately go back while B's deferred props are in flight + await page.goBack() + + // Wait for back navigation to A + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Filter B's deferred requests were cancelled + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('query parameter change cancels in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Change query parameter (same path, different query string = different URL) + await page.goto('/deferred-props/rapid-navigation/a?newparam=value') + + // Wait for new page load + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Original deferred requests were cancelled (URL changed due to query param) + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('hash-only change does NOT cancel in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Change hash only (same URL per isSameUrlWithoutHash) + await page.evaluate(() => { + window.location.hash = '#section2' + }) + + // Wait a moment for any potential cancellations + await page.waitForTimeout(500) + + // Wait for all deferred props to complete (not cancelled) + await page.waitForResponse( + (response) => + response.url().includes('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().includes('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify data is visible + await expect(page.getByText('users data for a')).toBeVisible() + await expect(page.getByText('stats data for a')).toBeVisible() + await expect(page.getByText('activity data for a')).toBeVisible() + + // No requests were cancelled (hash-only change) + expect(cancelledRequests).toHaveLength(0) + }) + + test('re-visiting same URL does NOT cancel in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + const completedRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + page.on('response', (response) => { + if ( + response.url().includes('rapid-navigation') && + response.request().headers()['x-inertia-partial-data'] && + response.status() === 200 + ) { + completedRequests.push(response.request().headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Re-visit the exact same URL we're already on + await page.getByRole('button', { name: 'Re-visit same URL' }).click() + + // Wait for page to settle + await page.waitForTimeout(500) + + // All original deferred props should complete (not cancelled - same URL) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify data is visible + await expect(page.getByText('users data for a')).toBeVisible() + await expect(page.getByText('stats data for a')).toBeVisible() + await expect(page.getByText('activity data for a')).toBeVisible() + + // No cancellations (same URL) + expect(cancelledRequests).toHaveLength(0) + }) + + test('plain reload() does NOT cancel in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Call plain reload() with no options + await page.getByRole('button', { name: 'Plain reload' }).click() + + // Wait for reload to process + await page.waitForTimeout(500) + + // All deferred props should complete (same URL, no cancellation) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // Verify data is visible + await expect(page.getByText('users data for a')).toBeVisible() + await expect(page.getByText('stats data for a')).toBeVisible() + await expect(page.getByText('activity data for a')).toBeVisible() + + // No cancellations (same URL reload) + expect(cancelledRequests).toHaveLength(0) + }) + + test('reload with both only and except does NOT cancel in-flight deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Reload with both only and except (same URL) + await page.getByRole('button', { name: 'Reload with only and except' }).click() + + // Wait for reload to process + await page.waitForTimeout(500) + + // Original deferred props should complete (same URL, no cancellation) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'stats' && + response.status() === 200, + ) + await page.waitForResponse( + (response) => + response.url().endsWith('/a') && + response.request().headers()['x-inertia-partial-data'] === 'activity' && + response.status() === 200, + ) + + // No cancellations (same URL) + expect(cancelledRequests).toHaveLength(0) + }) + + test('navigate to different URL with except parameter DOES cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Navigate to different URL with except parameter (URL changed) + await page.getByRole('button', { name: 'Visit B with except' }).click() + + // Wait for filter B to load + await expect(page.getByText('Current filter: b')).toBeVisible() + + // Filter A's deferred requests were cancelled (URL changed, except doesn't prevent cancellation) + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('navigate to different URL with both only and except DOES cancel deferred props', async ({ page }) => { + const cancelledRequests: string[] = [] + + page.on('requestfailed', (request) => { + if (request.url().includes('rapid-navigation') && request.headers()['x-inertia-partial-data']) { + cancelledRequests.push(request.headers()['x-inertia-partial-data']) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Navigate to different URL with both only and except + // Note: only+except causes partial merge, filter won't update (same component) + // But cancellation should still happen because URL changed + await page.getByRole('button', { name: 'Visit B with only and except', exact: true }).click() + + // Wait for the partial update to complete (users prop from B, stats excluded) + await page.waitForResponse( + (response) => + response.url().endsWith('/b') && + response.request().headers()['x-inertia-partial-data'] === 'users' && + response.status() === 200, + ) + + // Verify users data from B loaded (even though filter still shows 'a') + await expect(page.getByText('users data for b')).toBeVisible() + + // Filter A's deferred requests were cancelled (URL changed) + expect(cancelledRequests).toContain('users') + expect(cancelledRequests).toContain('stats') + expect(cancelledRequests).toContain('activity') + expect(cancelledRequests).toHaveLength(3) + }) + + test('prefetch continues when navigating to different URL (not cancelled)', async ({ page }) => { + const cancelledPrefetches: string[] = [] + const completedPrefetches: string[] = [] + + page.on('requestfailed', (request) => { + const headers = request.headers() + if (headers.purpose === 'prefetch') { + const url = new URL(request.url()) + cancelledPrefetches.push(url.pathname) + } + }) + + page.on('response', (response) => { + const headers = response.request().headers() + if (headers.purpose === 'prefetch' && response.status() === 200) { + const url = new URL(response.url()) + completedPrefetches.push(url.pathname) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Set up response listener BEFORE clicking prefetch + const prefetchPromise = page.waitForResponse( + (response) => + response.url().includes('/deferred-props/rapid-navigation/b') && + response.request().headers().purpose === 'prefetch' && + response.status() === 200, + ) + + // Start prefetch for Filter B + await page.getByRole('button', { name: 'Prefetch Filter B' }).click() + + // Immediately navigate to a different page (not B) + await page.getByRole('link', { name: 'Navigate Away' }).click() + await expect(page.getByText('Loading foo...')).toBeVisible() + + // Wait for prefetch to complete (it should NOT be cancelled) + await prefetchPromise + + // Prefetch for Filter B should have completed (not cancelled) + expect(completedPrefetches).toContain('/deferred-props/rapid-navigation/b') + expect(cancelledPrefetches).not.toContain('/deferred-props/rapid-navigation/b') + }) + + test('deferred props cancelled but prefetch preserved on navigation', async ({ page }) => { + const cancelledDeferreds: string[] = [] + const cancelledPrefetches: string[] = [] + const completedPrefetches: string[] = [] + + page.on('requestfailed', (request) => { + const headers = request.headers() + if (headers.purpose === 'prefetch') { + const url = new URL(request.url()) + cancelledPrefetches.push(url.pathname) + } else if (headers['x-inertia-partial-data']) { + cancelledDeferreds.push(headers['x-inertia-partial-data']) + } + }) + + page.on('response', (response) => { + const headers = response.request().headers() + if (headers.purpose === 'prefetch' && response.status() === 200) { + const url = new URL(response.url()) + completedPrefetches.push(url.pathname) + } + }) + + // Navigate to filter A (with deferred props loading) + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + await expect(page.getByText('Loading users...')).toBeVisible() + + // Set up response listener BEFORE clicking prefetch + const prefetchPromise = page.waitForResponse( + (response) => + response.url().includes('/deferred-props/page-1') && + response.request().headers().purpose === 'prefetch' && + response.status() === 200, + ) + + // Start prefetch for Page 1 + await page.getByRole('button', { name: 'Prefetch Page 1' }).click() + + // Navigate to Filter B (should cancel A's deferred props but NOT the prefetch) + await page.getByRole('link', { name: 'Filter B' }).click() + await expect(page.getByText('Current filter: b')).toBeVisible() + + // Wait for prefetch to complete + await prefetchPromise + + // Deferred props from A should be cancelled + expect(cancelledDeferreds).toContain('users') + expect(cancelledDeferreds).toContain('stats') + expect(cancelledDeferreds).toContain('activity') + + // But prefetch should complete (not cancelled) + expect(completedPrefetches).toContain('/deferred-props/page-1') + expect(cancelledPrefetches).not.toContain('/deferred-props/page-1') + }) + + test('multiple prefetches continue after navigation', async ({ page }) => { + const cancelledPrefetches: string[] = [] + const completedPrefetches: string[] = [] + + page.on('requestfailed', (request) => { + const headers = request.headers() + if (headers.purpose === 'prefetch') { + const url = new URL(request.url()) + cancelledPrefetches.push(url.pathname) + } + }) + + page.on('response', (response) => { + const headers = response.request().headers() + if (headers.purpose === 'prefetch' && response.status() === 200) { + const url = new URL(response.url()) + completedPrefetches.push(url.pathname) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Set up response listeners BEFORE clicking prefetch + const prefetchBPromise = page.waitForResponse( + (response) => + response.url().includes('/deferred-props/rapid-navigation/b') && + response.request().headers().purpose === 'prefetch' && + response.status() === 200, + ) + const prefetchPage1Promise = page.waitForResponse( + (response) => + response.url().includes('/deferred-props/page-1') && + response.request().headers().purpose === 'prefetch' && + response.status() === 200, + ) + + // Start multiple prefetches + await page.getByRole('button', { name: 'Prefetch Filter B' }).click() + await page.getByRole('button', { name: 'Prefetch Page 1' }).click() + + // Navigate to Filter C + await page.getByRole('link', { name: 'Filter C' }).click() + await expect(page.getByText('Current filter: c')).toBeVisible() + + // Wait for both prefetches to complete + await prefetchBPromise + await prefetchPage1Promise + + // Both prefetches should complete (not cancelled) + expect(completedPrefetches).toContain('/deferred-props/rapid-navigation/b') + expect(completedPrefetches).toContain('/deferred-props/page-1') + expect(cancelledPrefetches).toHaveLength(0) + }) + + test('prefetch not cancelled by same-URL reload', async ({ page }) => { + const cancelledPrefetches: string[] = [] + const completedPrefetches: string[] = [] + + page.on('requestfailed', (request) => { + const headers = request.headers() + if (headers.purpose === 'prefetch') { + const url = new URL(request.url()) + cancelledPrefetches.push(url.pathname) + } + }) + + page.on('response', (response) => { + const headers = response.request().headers() + if (headers.purpose === 'prefetch' && response.status() === 200) { + const url = new URL(response.url()) + completedPrefetches.push(url.pathname) + } + }) + + // Navigate to filter A + await page.goto('/deferred-props/rapid-navigation/a') + await expect(page.getByText('Current filter: a')).toBeVisible() + + // Set up response listener BEFORE clicking prefetch + const prefetchPromise = page.waitForResponse( + (response) => + response.url().includes('/deferred-props/page-1') && + response.request().headers().purpose === 'prefetch' && + response.status() === 200, + ) + + // Start prefetch + await page.getByRole('button', { name: 'Prefetch Page 1' }).click() + + // Do a reload (same URL - shouldn't cancel anything) + await page.getByRole('button', { name: 'Plain reload' }).click() + + // Wait for prefetch to complete + await prefetchPromise + + // Prefetch should complete (not cancelled by same-URL operation) + expect(completedPrefetches).toContain('/deferred-props/page-1') + expect(cancelledPrefetches).toHaveLength(0) + }) +})