Skip to content
4 changes: 2 additions & 2 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ export function useLinkProps<
}

const enqueueIntentPreload = (e: React.MouseEvent | React.FocusEvent) => {
if (disabled || !preload) return
if (disabled || preload !== 'intent') return

if (!preloadDelay) {
doPreload()
Expand All @@ -682,7 +682,7 @@ export function useLinkProps<
}

const handleTouchStart = (_: React.TouchEvent) => {
if (disabled || !preload) return
if (disabled || preload !== 'intent') return
doPreload()
}

Expand Down
108 changes: 108 additions & 0 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4497,6 +4497,114 @@ describe('Link', () => {
expect(mock).toHaveBeenCalledTimes(1)
})

test.each([undefined, false, 'render', 'viewport'] as const)(
'Link.preload="%s" should not preload on focus, hover, or touchstart',
async (preloadMode) => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link
to="/about"
{...(preloadMode === undefined ? {} : { preload: preloadMode })}
>
About Link
</Link>
</>
),
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: () => <h1>About Heading</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
defaultPreload: false,
defaultPreloadDelay: 0,
history,
})

const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')

render(<RouterProvider router={router} />)

const aboutLink = await screen.findByRole('link', { name: 'About Link' })
expect(aboutLink).toBeInTheDocument()

if (preloadMode === 'render') {
await waitFor(() =>
expect(preloadRouteSpy.mock.calls.length).toBeGreaterThan(0),
)
}

const baselineCalls = preloadRouteSpy.mock.calls.length

fireEvent.focus(aboutLink)
fireEvent.mouseOver(aboutLink)
fireEvent.touchStart(aboutLink)

await sleep(100)
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls)
},
)

test('Link.preload="intent" should preload on focus, hover, and touchstart', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link to="/about" preload="intent">
About Link
</Link>
</>
),
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: () => <h1>About Heading</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
defaultPreload: false,
defaultPreloadDelay: 0,
history,
})

const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')

render(<RouterProvider router={router} />)

const aboutLink = await screen.findByRole('link', { name: 'About Link' })
expect(aboutLink).toBeInTheDocument()

const baselineCalls = preloadRouteSpy.mock.calls.length

fireEvent.focus(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 1),
)

fireEvent.mouseOver(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 2),
)

fireEvent.touchStart(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 3),
)
})

test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
Expand Down
1 change: 0 additions & 1 deletion packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export type {
ResolveCurrentPath,
ResolveParentPath,
ResolveRelativePath,
LinkCurrentTargetElement,
FindDescendantToPaths,
InferDescendantToPaths,
RelativeToPath,
Expand Down
4 changes: 0 additions & 4 deletions packages/router-core/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,8 +701,4 @@ export type LinkOptions<
TMaskTo extends string = '.',
> = NavigateOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & LinkOptionsProps

export type LinkCurrentTargetElement = {
preloadTimeout?: null | ReturnType<typeof setTimeout>
}

export const preloadWarning = 'Error preloading route! ☝️'
43 changes: 23 additions & 20 deletions packages/solid-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { useHydrated } from './ClientOnly'
import type {
AnyRouter,
Constrain,
LinkCurrentTargetElement,
LinkOptions,
RegisteredRouter,
RoutePaths,
Expand All @@ -32,6 +31,8 @@ import type {
ValidateLinkOptionsArray,
} from './typePrimitives'

const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>()

export function useLinkProps<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends RoutePaths<TRouter['routeTree']> | string = string,
Expand Down Expand Up @@ -349,37 +350,39 @@ export function useLinkProps<
}

const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
if (local.disabled || !preload()) return
const eventTarget = (e.currentTarget ||
e.target ||
{}) as LinkCurrentTargetElement
if (local.disabled || preload() !== 'intent') return

if (eventTarget.preloadTimeout) {
if (!preloadDelay()) {
doPreload()
return
}

eventTarget.preloadTimeout = setTimeout(() => {
eventTarget.preloadTimeout = null
doPreload()
}, preloadDelay())
const eventTarget = e.currentTarget || e.target

if (!eventTarget || timeoutMap.has(eventTarget)) return

timeoutMap.set(
eventTarget,
setTimeout(() => {
timeoutMap.delete(eventTarget)
doPreload()
}, preloadDelay()),
)
}

const handleTouchStart = (_: TouchEvent) => {
if (local.disabled) return
if (preload()) {
doPreload()
}
if (local.disabled || preload() !== 'intent') return
doPreload()
}

const handleLeave = (e: MouseEvent | FocusEvent) => {
if (local.disabled) return
const eventTarget = (e.currentTarget ||
e.target ||
{}) as LinkCurrentTargetElement
const eventTarget = e.currentTarget || e.target

if (eventTarget.preloadTimeout) {
clearTimeout(eventTarget.preloadTimeout)
eventTarget.preloadTimeout = null
if (eventTarget) {
const id = timeoutMap.get(eventTarget)
clearTimeout(id)
timeoutMap.delete(eventTarget)
}
}

Expand Down
114 changes: 113 additions & 1 deletion packages/solid-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1490,7 +1490,11 @@ describe('Link', () => {
return (
<>
<h1>Index</h1>
<Link to="/posts/$postId" params={{ postId: 'id1' }}>
<Link
to="/posts/$postId"
params={{ postId: 'id1' }}
preloadDelay={0}
>
To first post
</Link>
</>
Expand Down Expand Up @@ -4481,6 +4485,114 @@ describe('Link', () => {
expect(mock).toHaveBeenCalledTimes(1)
})

test.each([undefined, false, 'render', 'viewport'] as const)(
'Link.preload="%s" should not preload on focus, hover, or touchstart',
async (preloadMode) => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link
to="/about"
{...(preloadMode === undefined ? {} : { preload: preloadMode })}
>
About Link
</Link>
</>
),
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: () => <h1>About Heading</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
defaultPreload: false,
defaultPreloadDelay: 0,
history,
})

const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')

render(() => <RouterProvider router={router} />)

const aboutLink = await screen.findByRole('link', { name: 'About Link' })
expect(aboutLink).toBeInTheDocument()

if (preloadMode === 'render') {
await waitFor(() =>
expect(preloadRouteSpy.mock.calls.length).toBeGreaterThan(0),
)
}

const baselineCalls = preloadRouteSpy.mock.calls.length

fireEvent.focus(aboutLink)
fireEvent.mouseOver(aboutLink)
fireEvent.touchStart(aboutLink)

await sleep(100)
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls)
},
)

test('Link.preload="intent" should preload on focus, hover, and touchstart', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link to="/about" preload="intent">
About Link
</Link>
</>
),
})
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/about',
component: () => <h1>About Heading</h1>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
defaultPreload: false,
defaultPreloadDelay: 0,
history,
})

const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')

render(() => <RouterProvider router={router} />)

const aboutLink = await screen.findByRole('link', { name: 'About Link' })
expect(aboutLink).toBeInTheDocument()

const baselineCalls = preloadRouteSpy.mock.calls.length

fireEvent.focus(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 1),
)

fireEvent.mouseOver(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 2),
)

fireEvent.touchStart(aboutLink)
await waitFor(() =>
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 3),
)
})

test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
Expand Down
Loading
Loading