Skip to content

Commit f58d457

Browse files
authored
fix(react-router,solid-router,vue-router): correct preload triggers for "intent" option (#6747)
1 parent 9fd7fb9 commit f58d457

8 files changed

Lines changed: 383 additions & 52 deletions

File tree

packages/react-router/src/link.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,7 @@ export function useLinkProps<
661661
}
662662

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

666666
if (!preloadDelay) {
667667
doPreload()
@@ -682,7 +682,7 @@ export function useLinkProps<
682682
}
683683

684684
const handleTouchStart = (_: React.TouchEvent) => {
685-
if (disabled || !preload) return
685+
if (disabled || preload !== 'intent') return
686686
doPreload()
687687
}
688688

packages/react-router/tests/link.test.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4497,6 +4497,114 @@ describe('Link', () => {
44974497
expect(mock).toHaveBeenCalledTimes(1)
44984498
})
44994499

4500+
test.each([undefined, false, 'render', 'viewport'] as const)(
4501+
'Link.preload="%s" should not preload on focus, hover, or touchstart',
4502+
async (preloadMode) => {
4503+
const rootRoute = createRootRoute()
4504+
const indexRoute = createRoute({
4505+
getParentRoute: () => rootRoute,
4506+
path: '/',
4507+
component: () => (
4508+
<>
4509+
<h1>Index Heading</h1>
4510+
<Link
4511+
to="/about"
4512+
{...(preloadMode === undefined ? {} : { preload: preloadMode })}
4513+
>
4514+
About Link
4515+
</Link>
4516+
</>
4517+
),
4518+
})
4519+
const aboutRoute = createRoute({
4520+
getParentRoute: () => rootRoute,
4521+
path: '/about',
4522+
component: () => <h1>About Heading</h1>,
4523+
})
4524+
4525+
const router = createRouter({
4526+
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
4527+
defaultPreload: false,
4528+
defaultPreloadDelay: 0,
4529+
history,
4530+
})
4531+
4532+
const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
4533+
4534+
render(<RouterProvider router={router} />)
4535+
4536+
const aboutLink = await screen.findByRole('link', { name: 'About Link' })
4537+
expect(aboutLink).toBeInTheDocument()
4538+
4539+
if (preloadMode === 'render') {
4540+
await waitFor(() =>
4541+
expect(preloadRouteSpy.mock.calls.length).toBeGreaterThan(0),
4542+
)
4543+
}
4544+
4545+
const baselineCalls = preloadRouteSpy.mock.calls.length
4546+
4547+
fireEvent.focus(aboutLink)
4548+
fireEvent.mouseOver(aboutLink)
4549+
fireEvent.touchStart(aboutLink)
4550+
4551+
await sleep(100)
4552+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls)
4553+
},
4554+
)
4555+
4556+
test('Link.preload="intent" should preload on focus, hover, and touchstart', async () => {
4557+
const rootRoute = createRootRoute()
4558+
const indexRoute = createRoute({
4559+
getParentRoute: () => rootRoute,
4560+
path: '/',
4561+
component: () => (
4562+
<>
4563+
<h1>Index Heading</h1>
4564+
<Link to="/about" preload="intent">
4565+
About Link
4566+
</Link>
4567+
</>
4568+
),
4569+
})
4570+
const aboutRoute = createRoute({
4571+
getParentRoute: () => rootRoute,
4572+
path: '/about',
4573+
component: () => <h1>About Heading</h1>,
4574+
})
4575+
4576+
const router = createRouter({
4577+
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
4578+
defaultPreload: false,
4579+
defaultPreloadDelay: 0,
4580+
history,
4581+
})
4582+
4583+
const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
4584+
4585+
render(<RouterProvider router={router} />)
4586+
4587+
const aboutLink = await screen.findByRole('link', { name: 'About Link' })
4588+
expect(aboutLink).toBeInTheDocument()
4589+
4590+
const baselineCalls = preloadRouteSpy.mock.calls.length
4591+
4592+
fireEvent.focus(aboutLink)
4593+
await waitFor(() =>
4594+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 1),
4595+
)
4596+
4597+
fireEvent.mouseOver(aboutLink)
4598+
await waitFor(() =>
4599+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 2),
4600+
)
4601+
4602+
fireEvent.touchStart(aboutLink)
4603+
await waitFor(() =>
4604+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 3),
4605+
)
4606+
})
4607+
45004608
test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => {
45014609
const rootRoute = createRootRoute()
45024610
const indexRoute = createRoute({

packages/router-core/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ export type {
1414
ResolveCurrentPath,
1515
ResolveParentPath,
1616
ResolveRelativePath,
17-
LinkCurrentTargetElement,
1817
FindDescendantToPaths,
1918
InferDescendantToPaths,
2019
RelativeToPath,

packages/router-core/src/link.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -701,8 +701,4 @@ export type LinkOptions<
701701
TMaskTo extends string = '.',
702702
> = NavigateOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & LinkOptionsProps
703703

704-
export type LinkCurrentTargetElement = {
705-
preloadTimeout?: null | ReturnType<typeof setTimeout>
706-
}
707-
708704
export const preloadWarning = 'Error preloading route! ☝️'

packages/solid-router/src/link.tsx

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import { useHydrated } from './ClientOnly'
2222
import type {
2323
AnyRouter,
2424
Constrain,
25-
LinkCurrentTargetElement,
2625
LinkOptions,
2726
RegisteredRouter,
2827
RoutePaths,
@@ -32,6 +31,8 @@ import type {
3231
ValidateLinkOptionsArray,
3332
} from './typePrimitives'
3433

34+
const timeoutMap = new WeakMap<EventTarget, ReturnType<typeof setTimeout>>()
35+
3536
export function useLinkProps<
3637
TRouter extends AnyRouter = RegisteredRouter,
3738
TFrom extends RoutePaths<TRouter['routeTree']> | string = string,
@@ -349,37 +350,39 @@ export function useLinkProps<
349350
}
350351

351352
const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
352-
if (local.disabled || !preload()) return
353-
const eventTarget = (e.currentTarget ||
354-
e.target ||
355-
{}) as LinkCurrentTargetElement
353+
if (local.disabled || preload() !== 'intent') return
356354

357-
if (eventTarget.preloadTimeout) {
355+
if (!preloadDelay()) {
356+
doPreload()
358357
return
359358
}
360359

361-
eventTarget.preloadTimeout = setTimeout(() => {
362-
eventTarget.preloadTimeout = null
363-
doPreload()
364-
}, preloadDelay())
360+
const eventTarget = e.currentTarget || e.target
361+
362+
if (!eventTarget || timeoutMap.has(eventTarget)) return
363+
364+
timeoutMap.set(
365+
eventTarget,
366+
setTimeout(() => {
367+
timeoutMap.delete(eventTarget)
368+
doPreload()
369+
}, preloadDelay()),
370+
)
365371
}
366372

367373
const handleTouchStart = (_: TouchEvent) => {
368-
if (local.disabled) return
369-
if (preload()) {
370-
doPreload()
371-
}
374+
if (local.disabled || preload() !== 'intent') return
375+
doPreload()
372376
}
373377

374378
const handleLeave = (e: MouseEvent | FocusEvent) => {
375379
if (local.disabled) return
376-
const eventTarget = (e.currentTarget ||
377-
e.target ||
378-
{}) as LinkCurrentTargetElement
380+
const eventTarget = e.currentTarget || e.target
379381

380-
if (eventTarget.preloadTimeout) {
381-
clearTimeout(eventTarget.preloadTimeout)
382-
eventTarget.preloadTimeout = null
382+
if (eventTarget) {
383+
const id = timeoutMap.get(eventTarget)
384+
clearTimeout(id)
385+
timeoutMap.delete(eventTarget)
383386
}
384387
}
385388

packages/solid-router/tests/link.test.tsx

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1490,7 +1490,11 @@ describe('Link', () => {
14901490
return (
14911491
<>
14921492
<h1>Index</h1>
1493-
<Link to="/posts/$postId" params={{ postId: 'id1' }}>
1493+
<Link
1494+
to="/posts/$postId"
1495+
params={{ postId: 'id1' }}
1496+
preloadDelay={0}
1497+
>
14941498
To first post
14951499
</Link>
14961500
</>
@@ -4481,6 +4485,114 @@ describe('Link', () => {
44814485
expect(mock).toHaveBeenCalledTimes(1)
44824486
})
44834487

4488+
test.each([undefined, false, 'render', 'viewport'] as const)(
4489+
'Link.preload="%s" should not preload on focus, hover, or touchstart',
4490+
async (preloadMode) => {
4491+
const rootRoute = createRootRoute()
4492+
const indexRoute = createRoute({
4493+
getParentRoute: () => rootRoute,
4494+
path: '/',
4495+
component: () => (
4496+
<>
4497+
<h1>Index Heading</h1>
4498+
<Link
4499+
to="/about"
4500+
{...(preloadMode === undefined ? {} : { preload: preloadMode })}
4501+
>
4502+
About Link
4503+
</Link>
4504+
</>
4505+
),
4506+
})
4507+
const aboutRoute = createRoute({
4508+
getParentRoute: () => rootRoute,
4509+
path: '/about',
4510+
component: () => <h1>About Heading</h1>,
4511+
})
4512+
4513+
const router = createRouter({
4514+
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
4515+
defaultPreload: false,
4516+
defaultPreloadDelay: 0,
4517+
history,
4518+
})
4519+
4520+
const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
4521+
4522+
render(() => <RouterProvider router={router} />)
4523+
4524+
const aboutLink = await screen.findByRole('link', { name: 'About Link' })
4525+
expect(aboutLink).toBeInTheDocument()
4526+
4527+
if (preloadMode === 'render') {
4528+
await waitFor(() =>
4529+
expect(preloadRouteSpy.mock.calls.length).toBeGreaterThan(0),
4530+
)
4531+
}
4532+
4533+
const baselineCalls = preloadRouteSpy.mock.calls.length
4534+
4535+
fireEvent.focus(aboutLink)
4536+
fireEvent.mouseOver(aboutLink)
4537+
fireEvent.touchStart(aboutLink)
4538+
4539+
await sleep(100)
4540+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls)
4541+
},
4542+
)
4543+
4544+
test('Link.preload="intent" should preload on focus, hover, and touchstart', async () => {
4545+
const rootRoute = createRootRoute()
4546+
const indexRoute = createRoute({
4547+
getParentRoute: () => rootRoute,
4548+
path: '/',
4549+
component: () => (
4550+
<>
4551+
<h1>Index Heading</h1>
4552+
<Link to="/about" preload="intent">
4553+
About Link
4554+
</Link>
4555+
</>
4556+
),
4557+
})
4558+
const aboutRoute = createRoute({
4559+
getParentRoute: () => rootRoute,
4560+
path: '/about',
4561+
component: () => <h1>About Heading</h1>,
4562+
})
4563+
4564+
const router = createRouter({
4565+
routeTree: rootRoute.addChildren([aboutRoute, indexRoute]),
4566+
defaultPreload: false,
4567+
defaultPreloadDelay: 0,
4568+
history,
4569+
})
4570+
4571+
const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
4572+
4573+
render(() => <RouterProvider router={router} />)
4574+
4575+
const aboutLink = await screen.findByRole('link', { name: 'About Link' })
4576+
expect(aboutLink).toBeInTheDocument()
4577+
4578+
const baselineCalls = preloadRouteSpy.mock.calls.length
4579+
4580+
fireEvent.focus(aboutLink)
4581+
await waitFor(() =>
4582+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 1),
4583+
)
4584+
4585+
fireEvent.mouseOver(aboutLink)
4586+
await waitFor(() =>
4587+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 2),
4588+
)
4589+
4590+
fireEvent.touchStart(aboutLink)
4591+
await waitFor(() =>
4592+
expect(preloadRouteSpy).toHaveBeenCalledTimes(baselineCalls + 3),
4593+
)
4594+
})
4595+
44844596
test('Router.preload="intent", pendingComponent renders during unresolved route loader', async () => {
44854597
const rootRoute = createRootRoute()
44864598
const indexRoute = createRoute({

0 commit comments

Comments
 (0)