diff --git a/packages/core/src/shouldIntercept.ts b/packages/core/src/shouldIntercept.ts index 5502e07bc..7879c4d6c 100644 --- a/packages/core/src/shouldIntercept.ts +++ b/packages/core/src/shouldIntercept.ts @@ -3,16 +3,20 @@ // are present in both types). export default function shouldIntercept( - event: Pick< - MouseEvent, - 'altKey' | 'ctrlKey' | 'defaultPrevented' | 'target' | 'currentTarget' | 'metaKey' | 'shiftKey' | 'button' - >, + event: + | Pick< + MouseEvent, + 'altKey' | 'ctrlKey' | 'defaultPrevented' | 'target' | 'currentTarget' | 'metaKey' | 'shiftKey' | 'button' + > + | KeyboardEvent + | import('react').MouseEvent, ): boolean { const isLink = (event.currentTarget as HTMLElement).tagName.toLowerCase() === 'a' return !( (event.target && (event?.target as HTMLElement).isContentEditable) || event.defaultPrevented || + (isLink && 'which' in event && event.which > 1) || (isLink && event.altKey) || (isLink && event.ctrlKey) || (isLink && event.metaKey) || diff --git a/packages/react/src/Link.ts b/packages/react/src/Link.ts index 7b607c926..425a89e41 100755 --- a/packages/react/src/Link.ts +++ b/packages/react/src/Link.ts @@ -170,7 +170,7 @@ const Link = forwardRef( }, prefetchModes) const regularEvents = { - onClick: (event) => { + onClick: (event: Parameters[0]) => { onClick(event) if (shouldIntercept(event)) { @@ -179,9 +179,6 @@ const Link = forwardRef( router.visit(url, visitParams) } }, - } - - const prefetchHoverEvents = { onMouseEnter: () => { hoverTimeout.current = window.setTimeout(() => { doPrefetch() @@ -190,6 +187,14 @@ const Link = forwardRef( onMouseLeave: () => { clearTimeout(hoverTimeout.current) }, + } + + const prefetchHoverEvents = { + onMouseEnter: regularEvents.onMouseEnter, + onMouseLeave: regularEvents.onMouseLeave, + onTouchStart: regularEvents.onMouseEnter, + onFocus: regularEvents.onMouseEnter, + onBlur: regularEvents.onMouseLeave, onClick: regularEvents.onClick, } diff --git a/packages/svelte/src/link.ts b/packages/svelte/src/link.ts index d390e29a5..02ffbb341 100644 --- a/packages/svelte/src/link.ts +++ b/packages/svelte/src/link.ts @@ -54,22 +54,31 @@ function link( let baseParams: VisitOptions let visitParams: VisitOptions - const regularEvents: ActionEventHandlers = { + const regularEvents = { click: (event) => { if (shouldIntercept(event)) { event.preventDefault() router.visit(href, visitParams) } }, - } - - const prefetchHoverEvents: ActionEventHandlers = { - mouseenter: () => (hoverTimeout = setTimeout(() => prefetch(), 75)), - mouseleave: () => clearTimeout(hoverTimeout), + mouseenter: () => { + hoverTimeout = setTimeout(() => prefetch(), 75) + }, + mouseleave: () => { + clearTimeout(hoverTimeout) + }, + } satisfies ActionEventHandlers + + const prefetchHoverEvents = { + mouseenter: regularEvents.mouseenter, + mouseleave: regularEvents.mouseleave, + touchstart: regularEvents.mouseenter, + focus: regularEvents.mouseenter, + blur: regularEvents.mouseleave, click: regularEvents.click, - } + } satisfies ActionEventHandlers - const prefetchClickEvents: ActionEventHandlers = { + const prefetchClickEvents = { mousedown: (event) => { if (shouldIntercept(event)) { event.preventDefault() @@ -86,7 +95,7 @@ function link( event.preventDefault() } }, - } + } satisfies ActionEventHandlers function update({ cacheFor = 0, prefetch = false, ...params }: ActionParameters) { prefetchModes = (() => { diff --git a/packages/vue3/src/link.ts b/packages/vue3/src/link.ts index 5e0a4eac0..0f1e816de 100755 --- a/packages/vue3/src/link.ts +++ b/packages/vue3/src/link.ts @@ -232,15 +232,12 @@ const Link: InertiaLink = defineComponent({ } const regularEvents = { - onClick: (event) => { + onClick: (event: Parameters[0]) => { if (shouldIntercept(event)) { event.preventDefault() router.visit(href.value, visitParams) } }, - } - - const prefetchHoverEvents = { onMouseenter: () => { hoverTimeout.value = setTimeout(() => { prefetch() @@ -249,6 +246,14 @@ const Link: InertiaLink = defineComponent({ onMouseleave: () => { clearTimeout(hoverTimeout.value) }, + } + + const prefetchHoverEvents = { + onMouseenter: regularEvents.onMouseenter, + onMouseleave: regularEvents.onMouseleave, + onTouchstart: regularEvents.onMouseenter, + onFocus: regularEvents.onMouseenter, + onBlur: regularEvents.onMouseleave, onClick: regularEvents.onClick, } diff --git a/tests/prefetch.spec.ts b/tests/prefetch.spec.ts index 313f53e17..2621f548c 100644 --- a/tests/prefetch.spec.ts +++ b/tests/prefetch.spec.ts @@ -36,7 +36,7 @@ test('will not prefetch current page', async ({ page }) => { await page.getByRole('link', { name: 'On Hover (Default)' }).hover() await page.waitForTimeout(100) // This is the page we're already on, so it shouldn't make a request - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) }) test('can prefetch using link props', async ({ page }) => { @@ -54,11 +54,11 @@ test('can prefetch using link props', async ({ page }) => { await page.getByRole('link', { name: 'On Mount' }).click() await isPrefetchPage(page, 2) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) await page.getByRole('link', { name: 'On Hover + Mount' }).click() await isPrefetchPage(page, 4) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) await page.getByRole('link', { name: 'On Click' }).hover() await page.mouse.down() @@ -67,13 +67,13 @@ test('can prefetch using link props', async ({ page }) => { requests.listen(page) await page.mouse.up() await isPrefetchPage(page, 3) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) requests.listen(page) await page.getByRole('link', { name: 'On Hover (Default)' }).hover() await page.getByRole('link', { name: 'On Click' }).hover() // If they just do a quick hover, it shouldn't make the request - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) await page.getByRole('link', { name: 'On Hover (Default)' }).hover() await page.waitForResponse('prefetch/1') @@ -82,7 +82,7 @@ test('can prefetch using link props', async ({ page }) => { requests.listen(page) await page.getByRole('link', { name: 'On Hover (Default)' }).click() await isPrefetchPage(page, 1) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) // Wait for the cache to timeout on the combo link await page.waitForTimeout(1200) @@ -94,7 +94,50 @@ test('can prefetch using link props', async ({ page }) => { requests.listen(page) await page.getByRole('link', { name: 'On Hover + Mount' }).click() await isPrefetchPage(page, 4) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) +}) + +test('can prefetch link on focus', async ({ page, browser }) => { + await page.goto('prefetch/2') + await isPrefetchPage(page, 2) + requests.listen(page) + // If they just do a quick focus, it shouldn't make the request + const link = page.getByRole('link', { exact: true, name: 'On Hover (Default)' }) + await link.focus() + await link.blur() + expect(requests.requests.length).toBe(0) + + await link.focus() + await page.waitForTimeout(80) + expect(requests.requests.length).toBe(1) + await isPrefetchPage(page, 2) + await page.keyboard.down('Enter') + await isPrefetchPage(page, 1) + expect(requests.requests.length).toBe(1) + + // Create a new context to simulate touchscreen + const context2 = await browser.newContext({ hasTouch: true }) + const page2 = await context2.newPage() + + // These two prefetch requests should be made on mount + const prefetch2 = page2.waitForResponse('prefetch/2') + const prefetch4 = page2.waitForResponse('prefetch/4') + + await page2.goto('prefetch/2') + + // These two prefetch requests should be made on mount + await prefetch2 + await prefetch4 + + requests.listen(page2) + + const box = await page2.getByRole('link', { exact: true, name: 'On Hover (Default)' }).boundingBox() + expect(box).not.toBeNull() + page2.touchscreen.tap(box!.x, box!.y) + await page2.waitForTimeout(75) + expect(requests.requests.length).toBe(1) + await isPrefetchPage(page2, 1) + await context2.close() }) test('can cache links with single cache value', async ({ page }) => { @@ -104,27 +147,27 @@ test('can cache links with single cache value', async ({ page }) => { // Click back and forth a couple of times to ensure no requests go out await hoverAndClick(page, '1s Expired (Number)', 3) - await expect(requests.requests.length).toBe(1) + expect(requests.requests.length).toBe(1) const lastLoaded1 = await page.locator('#last-loaded').textContent() await hoverAndClick(page, '1s Expired', 2) await isPrefetchSwrPage(page, 2) - await expect(requests.requests.length).toBe(2) + expect(requests.requests.length).toBe(2) const lastLoaded2 = await page.locator('#last-loaded').textContent() requests.listen(page) await page.getByRole('link', { exact: true, name: '1s Expired (Number)' }).click() await isPrefetchSwrPage(page, 3) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) const lastLoaded1New = await page.locator('#last-loaded').textContent() - await expect(lastLoaded1).toBe(lastLoaded1New) + expect(lastLoaded1).toBe(lastLoaded1New) await page.getByRole('link', { exact: true, name: '1s Expired' }).click() await isPrefetchSwrPage(page, 2) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) const lastLoaded2New = await page.locator('#last-loaded').textContent() - await expect(lastLoaded2).toBe(lastLoaded2New) + expect(lastLoaded2).toBe(lastLoaded2New) // Wait for cache to expire await page.waitForTimeout(1200) @@ -132,14 +175,14 @@ test('can cache links with single cache value', async ({ page }) => { requests.listenForFinished(page) await hoverAndClick(page, '1s Expired (Number)', 3) - await expect(requests.finished.length).toBe(1) + expect(requests.finished.length).toBe(1) const lastLoaded1Fresh = await page.locator('#last-loaded').textContent() - await expect(lastLoaded1).not.toBe(lastLoaded1Fresh) + expect(lastLoaded1).not.toBe(lastLoaded1Fresh) await hoverAndClick(page, '1s Expired', 2) - await expect(requests.finished.length).toBe(2) + expect(requests.finished.length).toBe(2) const lastLoaded2Fresh = await page.locator('#last-loaded').textContent() - await expect(lastLoaded2).not.toBe(lastLoaded2Fresh) + expect(lastLoaded2).not.toBe(lastLoaded2Fresh) }) test.skip('can do SWR when the link cacheFor prop has two values', async ({ page }) => { @@ -148,11 +191,11 @@ test.skip('can do SWR when the link cacheFor prop has two values', async ({ page requests.listen(page) await hoverAndClick(page, '1s Stale, 2s Expired (Number)', 5) - await expect(requests.requests.length).toBe(1) + expect(requests.requests.length).toBe(1) const lastLoaded1 = await page.locator('#last-loaded').textContent() await hoverAndClick(page, '1s Stale, 2s Expired', 4) - await expect(requests.requests.length).toBe(2) + expect(requests.requests.length).toBe(2) const lastLoaded2 = await page.locator('#last-loaded').textContent() requests.listen(page) @@ -160,15 +203,15 @@ test.skip('can do SWR when the link cacheFor prop has two values', async ({ page // Click back and forth a couple of times to ensure no requests go out await page.getByRole('link', { exact: true, name: '1s Stale, 2s Expired (Number)' }).click() await isPrefetchSwrPage(page, 5) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) const lastLoaded1New = await page.locator('#last-loaded').textContent() - await expect(lastLoaded1).toBe(lastLoaded1New) + expect(lastLoaded1).toBe(lastLoaded1New) await page.getByRole('link', { exact: true, name: '1s Stale, 2s Expired' }).click() await isPrefetchSwrPage(page, 4) - await expect(requests.requests.length).toBe(0) + expect(requests.requests.length).toBe(0) const lastLoaded2New = await page.locator('#last-loaded').textContent() - await expect(lastLoaded2).toBe(lastLoaded2New) + expect(lastLoaded2).toBe(lastLoaded2New) // Wait for stale time to pass await page.waitForTimeout(1200) @@ -181,23 +224,23 @@ test.skip('can do SWR when the link cacheFor prop has two values', async ({ page await page.getByRole('link', { exact: true, name: '1s Stale, 2s Expired (Number)' }).click() await isPrefetchSwrPage(page, 5) const lastLoaded1Stale = await page.locator('#last-loaded').textContent() - await expect(lastLoaded1).toBe(lastLoaded1Stale) + expect(lastLoaded1).toBe(lastLoaded1Stale) await promiseFor5 // await expect(requests.finished.length).toBe(1) await page.waitForTimeout(600) const lastLoaded1Fresh = await page.locator('#last-loaded').textContent() - await expect(lastLoaded1).not.toBe(lastLoaded1Fresh) + expect(lastLoaded1).not.toBe(lastLoaded1Fresh) const promiseFor4 = page.waitForResponse('prefetch/swr/4') await page.getByRole('link', { exact: true, name: '1s Stale, 2s Expired' }).click() await isPrefetchSwrPage(page, 4) const lastLoaded2Stale = await page.locator('#last-loaded').textContent() - await expect(lastLoaded2).toBe(lastLoaded2Stale) + expect(lastLoaded2).toBe(lastLoaded2Stale) await promiseFor4 // await expect(requests.finished.length).toBe(2) await page.waitForTimeout(100) const lastLoaded2Fresh = await page.locator('#last-loaded').textContent() - await expect(lastLoaded2).not.toBe(lastLoaded2Fresh) + expect(lastLoaded2).not.toBe(lastLoaded2Fresh) })