diff --git a/packages/react/src/runtime/pages-client.ts b/packages/react/src/runtime/pages-client.ts index ef1daf104..e3f6a875c 100644 --- a/packages/react/src/runtime/pages-client.ts +++ b/packages/react/src/runtime/pages-client.ts @@ -327,15 +327,11 @@ function RouteResource({ } }, [onCommit, resource, result]); - if (!result) { - return null; - } - - const pageData = result.pageData as ReactPageData; - const PageComponent = result.component as ReactPageComponent; - const routeParams = pageData.params ?? EMPTY_PARAMS; + const pageData = result?.pageData as ReactPageData | undefined; + const pageDataUrl = pageData?.url ?? ''; + const routeParams = pageData?.params ?? EMPTY_PARAMS; const routeRouter = useMemo(() => { - const pageUrl = encodedPageUrl(pageData.url); + const pageUrl = encodedPageUrl(pageDataUrl); return createRouterFacade( outerRouter!, () => { @@ -356,7 +352,14 @@ function RouteResource({ }, () => routeParams, ); - }, [outerRouter, pageData.url, resource.fetchUrl, resource.hash, routeParams]); + }, [outerRouter, pageDataUrl, resource.fetchUrl, resource.hash, routeParams]); + + // Hooks above run on every render, so a resource with no result keeps the hook count. + if (!result || !pageData) { + return null; + } + + const PageComponent = result.component as ReactPageComponent; let element = React.createElement(PageComponent, pageData.props); for (let i = result.layouts.length - 1; i >= 0; i--) { const Layout = result.layouts[i] as ReactLayoutComponent; @@ -559,11 +562,8 @@ function App({ if (activePendingNavigationId === prepared.id) { activePendingNavigationId = null; } - dispatch({ - type: NavigationFallback ? 'abandon' : 'finish', - resource: prepared, - location: currentUrl(), - }); + // The browser is leaving or downloading a file: keep the current page. + dispatch({ type: 'abandon', resource: prepared, location: currentUrl() }); resolveVisit(undefined); } else if (prepared.options._resolveOnShell) { pendingVisitSettlers.delete(prepared.id); diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index f936a54e4..ff4cbc3d2 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -526,6 +526,45 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') } }); + it('Link keeps the current page when the target downloads a file', async () => { + const page = await newPage(); + const errors: Array = []; + page.on('pageerror', (error) => { + errors.push(error.message); + }); + + try { + // A non-Void response makes the client fall back to a full page load, which only + // downloads the file, so the current page must stay on screen. + await page.route(BASE + '/users', (route) => + route.fulfill({ + status: 200, + headers: { + 'content-type': 'text/csv', + 'content-disposition': 'attachment; filename="users.csv"', + }, + body: 'id,name\n1,Ada\n', + }), + ); + await page.goto(BASE + '/link-behavior'); + await waitForHydration(page); + + const download = page.waitForEvent('download'); + await page.click('[data-testid="onclick-link"]'); + await download; + await page.waitForFunction( + () => + document.querySelector('[data-testid="navigation-state"]')?.textContent === 'idle' && + document.querySelector('h1')?.textContent === 'Link Behavior', + ); + + expect(errors).toEqual([]); + expect(page.url()).toBe(BASE + '/link-behavior'); + } finally { + await page.context().close(); + } + }); + it('Link hash navigation runs the navigation lifecycle', async () => { const page = await newPage(); try {