Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/solid-link-idle-preload-listeners.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@tanstack/solid-router': patch
---

Stop installing intent-preload listeners on `<Link>` when intent preloading is off.

`useLinkProps` handed out `onFocus`/`onBlur`/`onMouseEnter`/`onMouseLeave` (and the
mouse-over/out/touch-start pair) unconditionally, with the `preload() !== 'intent'`
check living _inside_ each handler. Solid does not delegate `mouseenter`,
`mouseleave`, `focus` or `blur`, so every anchor installed four real listeners that
did nothing but return — on a list view that is four per row (a 165-row board
measured 660 listeners whose only job was to bail).

The handlers are now resolved through getters: with intent preloading off the
property yields whatever the consumer passed (or `undefined`, which `spread()`
treats as removal), and nothing is attached. Behaviour is unchanged — the getters
stay reactive, so flipping `preload` back to `'intent'` re-runs the consuming
spread and attaches the composed handler.
32 changes: 25 additions & 7 deletions packages/solid-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,13 +522,31 @@ export function useLinkProps<

linkProps.ref = composedRef
linkProps.onClick = onClick
linkProps.onBlur = onBlur
linkProps.onFocus = onFocus
linkProps.onMouseEnter = onMouseEnter
linkProps.onMouseOver = onMouseOver
linkProps.onMouseLeave = onMouseLeave
linkProps.onMouseOut = onMouseOut
linkProps.onTouchStart = onTouchStart

// Intent preloading is the only thing these handlers do, and each of them
// already bails at a `preload() !== 'intent'` gate. Handing them out anyway
// is not free: Solid does not delegate mouseenter/mouseleave/focus/blur, so
// every anchor installs four real listeners that exist only to return. On a
// list view — a table of rows, a calendar of spans — that is four listeners
// per row for no behaviour at all.
//
// So when intent preloading is off, the property resolves to whatever the
// user passed (or undefined), and spread()/assign() installs nothing. The
// getters keep this reactive: flipping `preload` back to 'intent' re-runs
// the consuming spread, which attaches the composed handler then.
const onIntent =
(composed: (event: any) => void, user: () => unknown) => () =>
preload() === 'intent' ? composed : user()

defineGetters({
onBlur: onIntent(onBlur, () => local.onBlur),
onFocus: onIntent(onFocus, () => local.onFocus),
onMouseEnter: onIntent(onMouseEnter, () => local.onMouseEnter),
onMouseOver: onIntent(onMouseOver, () => local.onMouseOver),
onMouseLeave: onIntent(onMouseLeave, () => local.onMouseLeave),
onMouseOut: onIntent(onMouseOut, () => local.onMouseOut),
onTouchStart: onIntent(onTouchStart, () => local.onTouchStart),
})

defineGetters({
href: () => hrefOption()?.href,
Expand Down
115 changes: 115 additions & 0 deletions packages/solid-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4966,6 +4966,121 @@ describe('Link', () => {
},
)

test.each([false, 'viewport', 'render'] as const)(
'Router.preload="%s", Link should not install intent-preload listeners',
async (preload) => {
const addEventListener = vi.spyOn(
HTMLAnchorElement.prototype,
'addEventListener',
)

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link to="/">Index Link</Link>
</>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
defaultPreload: preload,
})

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

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

// mouseenter/mouseleave/focus/blur are not delegated by Solid, so every
// one of them is a real listener on the anchor.
const types = addEventListener.mock.calls.map(([type]) => type)
expect(types).not.toContain('mouseenter')
expect(types).not.toContain('mouseleave')
expect(types).not.toContain('focus')
expect(types).not.toContain('blur')

addEventListener.mockRestore()
},
)

test('Router.preload="intent", Link installs the intent-preload listeners', async () => {
const addEventListener = vi.spyOn(
HTMLAnchorElement.prototype,
'addEventListener',
)

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link to="/">Index Link</Link>
</>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
defaultPreload: 'intent',
})

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

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

const types = addEventListener.mock.calls.map(([type]) => type)
expect(types).toContain('mouseenter')
expect(types).toContain('mouseleave')

addEventListener.mockRestore()
})

test("Link.preload={false} still calls the user's own hover handlers", async () => {
const onMouseEnter = vi.fn()
const onFocus = vi.fn()

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link
to="/"
preload={false}
onMouseEnter={onMouseEnter}
onFocus={onFocus}
>
Index Link
</Link>
</>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
defaultPreload: 'intent',
})

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

const indexLink = await screen.findByRole('link', { name: 'Index Link' })
fireEvent.mouseEnter(indexLink)
fireEvent.focus(indexLink)

await waitFor(() => expect(onMouseEnter).toHaveBeenCalledTimes(1))
expect(onFocus).toHaveBeenCalledTimes(1)
})

test('Router.preload="viewport", should trigger the IntersectionObserver\'s observe and disconnect methods', async () => {
const rootRoute = createRootRoute()
const RouteComponent = () => {
Expand Down
Loading