Skip to content

Commit 4b9ed6c

Browse files
fix: don't leak internal props in Link (#7138)
1 parent 1d31393 commit 4b9ed6c

File tree

8 files changed

+323
-51
lines changed

8 files changed

+323
-51
lines changed

.changeset/calm-walls-begin.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/react-router': patch
3+
'@tanstack/solid-router': patch
4+
'@tanstack/vue-router': patch
5+
---
6+
7+
Fix `Link` to keep internal routing props like `preloadIntentProximity`, `from`, and `unsafeRelative` from leaking to rendered DOM elements across React, Solid, and Vue.

packages/react-router/src/link.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export function useLinkProps<
6464
to,
6565
preload: userPreload,
6666
preloadDelay: userPreloadDelay,
67+
preloadIntentProximity: _preloadIntentProximity,
6768
hashScrollIntoView,
6869
replace,
6970
startTransition,

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,96 @@ describe('Link', () => {
177177
).rejects.toThrow()
178178
})
179179

180+
test('does not forward internal Link props to the DOM', async () => {
181+
const consoleErrorSpy = vi
182+
.spyOn(console, 'error')
183+
.mockImplementation(() => undefined)
184+
const internalPropNames = [
185+
'activeProps',
186+
'inactiveProps',
187+
'activeOptions',
188+
'to',
189+
'from',
190+
'preload',
191+
'preloadDelay',
192+
'preloadIntentProximity',
193+
'hashScrollIntoView',
194+
'replace',
195+
'startTransition',
196+
'resetScroll',
197+
'viewTransition',
198+
'ignoreBlocker',
199+
'params',
200+
'search',
201+
'hash',
202+
'state',
203+
'mask',
204+
'reloadDocument',
205+
'unsafeRelative',
206+
].map((propName) => propName.toLowerCase())
207+
208+
const rootRoute = createRootRoute()
209+
const indexRoute = createRoute({
210+
getParentRoute: () => rootRoute,
211+
path: '/',
212+
component: () => (
213+
<Link
214+
to="/posts"
215+
from="/"
216+
activeProps={{ className: 'active' }}
217+
inactiveProps={{ className: 'inactive' }}
218+
activeOptions={{
219+
exact: true,
220+
includeHash: true,
221+
includeSearch: true,
222+
}}
223+
preload="intent"
224+
preloadDelay={50}
225+
preloadIntentProximity={123}
226+
hashScrollIntoView={true}
227+
replace={true}
228+
startTransition={true}
229+
resetScroll={true}
230+
viewTransition={true}
231+
ignoreBlocker={true}
232+
params={{}}
233+
search={{ foo: 'bar' }}
234+
hash="details"
235+
state={{}}
236+
mask={{ to: '/posts', hash: 'masked' }}
237+
reloadDocument={true}
238+
unsafeRelative="path"
239+
>
240+
Posts
241+
</Link>
242+
),
243+
})
244+
245+
const postsRoute = createRoute({
246+
getParentRoute: () => rootRoute,
247+
path: '/posts',
248+
component: () => <h1>Posts</h1>,
249+
})
250+
251+
const router = createRouter({
252+
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
253+
history,
254+
})
255+
256+
render(<RouterProvider router={router} />)
257+
258+
const postsLink = await screen.findByRole('link', { name: 'Posts' })
259+
const renderedAttributeNames = postsLink
260+
.getAttributeNames()
261+
.map((attributeName) => attributeName.toLowerCase())
262+
263+
for (const propName of internalPropNames) {
264+
expect(renderedAttributeNames).not.toContain(propName)
265+
}
266+
267+
expect(consoleErrorSpy).not.toHaveBeenCalled()
268+
})
269+
180270
test('when the current route is the root', async () => {
181271
const rootRoute = createRootRoute()
182272
const indexRoute = createRoute({

packages/solid-router/src/link.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function useLinkProps<
6363
'to',
6464
'preload',
6565
'preloadDelay',
66+
'preloadIntentProximity',
6667
'hashScrollIntoView',
6768
'replace',
6869
'startTransition',
@@ -120,6 +121,7 @@ export function useLinkProps<
120121
'mask',
121122
'reloadDocument',
122123
'unsafeRelative',
124+
'from',
123125
])
124126

125127
const currentLocation = Solid.createMemo(

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,90 @@ describe('Link', () => {
174174
).rejects.toThrow()
175175
})
176176

177+
test('does not forward internal Link props to the DOM', async () => {
178+
const internalPropNames = [
179+
'activeProps',
180+
'inactiveProps',
181+
'activeOptions',
182+
'to',
183+
'from',
184+
'preload',
185+
'preloadDelay',
186+
'preloadIntentProximity',
187+
'hashScrollIntoView',
188+
'replace',
189+
'startTransition',
190+
'resetScroll',
191+
'viewTransition',
192+
'ignoreBlocker',
193+
'params',
194+
'search',
195+
'hash',
196+
'state',
197+
'mask',
198+
'reloadDocument',
199+
'unsafeRelative',
200+
].map((propName) => propName.toLowerCase())
201+
202+
const rootRoute = createRootRoute()
203+
const indexRoute = createRoute({
204+
getParentRoute: () => rootRoute,
205+
path: '/',
206+
component: () => (
207+
<Link
208+
to="/posts"
209+
from="/"
210+
activeProps={{ class: 'active' }}
211+
inactiveProps={{ class: 'inactive' }}
212+
activeOptions={{
213+
exact: true,
214+
includeHash: true,
215+
includeSearch: true,
216+
}}
217+
preload="intent"
218+
preloadDelay={50}
219+
preloadIntentProximity={123}
220+
hashScrollIntoView={true}
221+
replace={true}
222+
startTransition={true}
223+
resetScroll={true}
224+
viewTransition={true}
225+
ignoreBlocker={true}
226+
params={{}}
227+
search={{ foo: 'bar' }}
228+
hash="details"
229+
state={{}}
230+
mask={{ to: '/posts', hash: 'masked' }}
231+
reloadDocument={true}
232+
unsafeRelative="path"
233+
>
234+
Posts
235+
</Link>
236+
),
237+
})
238+
239+
const postsRoute = createRoute({
240+
getParentRoute: () => rootRoute,
241+
path: '/posts',
242+
component: () => <h1>Posts</h1>,
243+
})
244+
245+
const router = createRouter({
246+
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
247+
})
248+
249+
render(() => <RouterProvider router={router} />)
250+
251+
const postsLink = await screen.findByRole('link', { name: 'Posts' })
252+
const renderedAttributeNames = postsLink
253+
.getAttributeNames()
254+
.map((attributeName) => attributeName.toLowerCase())
255+
256+
for (const propName of internalPropNames) {
257+
expect(renderedAttributeNames).not.toContain(propName)
258+
}
259+
})
260+
177261
test('when a Link has children', async () => {
178262
const ChildComponent = vi.fn().mockReturnValue(<button>Posts</button>)
179263
const rootRoute = createRootRoute()

packages/vue-router/src/link.tsx

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -597,58 +597,57 @@ function getLinkEventHandlers(
597597
}
598598
}
599599

600-
const propsUnsafeToSpread = new Set([
601-
'activeProps',
602-
'inactiveProps',
603-
'activeOptions',
604-
'to',
605-
'preload',
606-
'preloadDelay',
607-
'hashScrollIntoView',
608-
'replace',
609-
'startTransition',
610-
'resetScroll',
611-
'viewTransition',
612-
'children',
613-
'target',
614-
'disabled',
615-
'style',
616-
'class',
617-
'onClick',
618-
'onBlur',
619-
'onFocus',
620-
'onMouseEnter',
621-
'onMouseenter',
622-
'onMouseLeave',
623-
'onMouseleave',
624-
'onMouseOver',
625-
'onMouseover',
626-
'onMouseOut',
627-
'onMouseout',
628-
'onTouchStart',
629-
'onTouchstart',
630-
'ignoreBlocker',
631-
'params',
632-
'search',
633-
'hash',
634-
'state',
635-
'mask',
636-
'reloadDocument',
637-
'_asChild',
638-
'from',
639-
'additionalProps',
640-
])
641-
642-
// Create safe props that can be spread
643600
const getPropsSafeToSpread = (options: AnyLinkPropsOptions) => {
644-
const result: Record<string, unknown> = {}
645-
for (const key in options) {
646-
if (!propsUnsafeToSpread.has(key)) {
647-
result[key] = (options as Record<string, unknown>)[key]
648-
}
649-
}
650-
651-
return result
601+
const {
602+
activeProps: _activeProps,
603+
inactiveProps: _inactiveProps,
604+
activeOptions: _activeOptions,
605+
to: _to,
606+
preload: _preload,
607+
preloadDelay: _preloadDelay,
608+
preloadIntentProximity: _preloadIntentProximity,
609+
hashScrollIntoView: _hashScrollIntoView,
610+
replace: _replace,
611+
startTransition: _startTransition,
612+
resetScroll: _resetScroll,
613+
viewTransition: _viewTransition,
614+
children: _children,
615+
target: _target,
616+
disabled: _disabled,
617+
style: _style,
618+
class: _class,
619+
onClick: _onClick,
620+
onBlur: _onBlur,
621+
onFocus: _onFocus,
622+
onMouseEnter: _onMouseEnter,
623+
onMouseenter: _onMouseenter,
624+
onMouseLeave: _onMouseLeave,
625+
onMouseleave: _onMouseleave,
626+
onMouseOver: _onMouseOver,
627+
onMouseover: _onMouseover,
628+
onMouseOut: _onMouseOut,
629+
onMouseout: _onMouseout,
630+
onTouchStart: _onTouchStart,
631+
onTouchstart: _onTouchstart,
632+
ignoreBlocker: _ignoreBlocker,
633+
params: _params,
634+
search: _search,
635+
hash: _hash,
636+
state: _state,
637+
mask: _mask,
638+
reloadDocument: _reloadDocument,
639+
unsafeRelative: _unsafeRelative,
640+
_asChild: __asChild,
641+
from: _from,
642+
additionalProps: _additionalProps,
643+
...propsSafeToSpread
644+
} = options as AnyLinkPropsOptions & {
645+
additionalProps?: unknown
646+
children?: unknown
647+
_asChild?: unknown
648+
}
649+
650+
return propsSafeToSpread
652651
}
653652

654653
function getIsActive({
@@ -872,6 +871,7 @@ const LinkImpl = Vue.defineComponent({
872871
'to',
873872
'preload',
874873
'preloadDelay',
874+
'preloadIntentProximity',
875875
'activeProps',
876876
'inactiveProps',
877877
'activeOptions',

0 commit comments

Comments
 (0)