Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/quiet-doors-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---

clean intersection observer options in link component

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Describe the released observer API change.

The current text only mentions link-component cleanup. State that disabled state is now separate from IntersectionObserverInit options in the React, Solid, and Vue observer helpers.

Proposed wording
-clean intersection observer options in link component
+Separate disabled state from intersection observer options in React, Solid, and Vue router helpers.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clean intersection observer options in link component
Separate disabled state from intersection observer options in React, Solid, and Vue router helpers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/quiet-doors-double.md at line 7, Update the changeset description
to document the released observer API change: in the React, Solid, and Vue
observer helpers, the disabled state is now separate from the
IntersectionObserverInit options. Replace the current link-component cleanup
wording with this API-focused description.

2 changes: 1 addition & 1 deletion packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ export function useLinkProps<
innerRef,
preloadViewportIoCallback,
intersectionObserverOptions,
{ disabled: !!disabled || !(preload === 'viewport') },
!!disabled || preload !== 'viewport',
)

// eslint-disable-next-line react-hooks/rules-of-hooks
Expand Down
10 changes: 5 additions & 5 deletions packages/react-router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function usePrevious<T>(value: T): T | null {
*
* @param ref - The ref to observe
* @param intersectionObserverOptions - The options to pass to the IntersectionObserver
* @param options - The options to pass to the hook
* @param disabled - Whether observation is disabled
* @param callback - The callback to call when the intersection changes
* @returns The IntersectionObserver instance
* @example
Expand All @@ -78,7 +78,7 @@ export function usePrevious<T>(value: T): T | null {
* ref,
* (entry) => { doSomething(entry) },
* { rootMargin: '10px' },
* { disabled: false }
* false
* )
* return <div ref={ref} />
* ```
Expand All @@ -87,12 +87,12 @@ export function useIntersectionObserver<T extends Element>(
ref: React.RefObject<T | null>,
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
options: { disabled?: boolean } = {},
disabled?: boolean,
) {
React.useEffect(() => {
if (
!ref.current ||
options.disabled ||
disabled ||
typeof IntersectionObserver !== 'function'
) {
return
Expand All @@ -107,7 +107,7 @@ export function useIntersectionObserver<T extends Element>(
return () => {
observer.disconnect()
}
}, [callback, intersectionObserverOptions, options.disabled, ref])
}, [callback, disabled, intersectionObserverOptions, ref])
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function useLinkProps<
ref,
preloadViewportIoCallback,
{ rootMargin: '100px' },
{ disabled: !!local.disabled || !(preload() === 'viewport') },
!!local.disabled || preload() !== 'viewport',
)

Solid.createEffect(() => {
Expand Down
8 changes: 4 additions & 4 deletions packages/solid-router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Solid from 'solid-js'
*
* @param ref - The ref to observe
* @param intersectionObserverOptions - The options to pass to the IntersectionObserver
* @param options - The options to pass to the hook
* @param disabled - Whether observation is disabled
* @param callback - The callback to call when the intersection changes
* @returns The IntersectionObserver instance
* @example
Expand All @@ -20,7 +20,7 @@ import * as Solid from 'solid-js'
* ref,
* (entry) => { doSomething(entry) },
* { rootMargin: '10px' },
* { disabled: false }
* false
* )
* return <div ref={ref} />
* ```
Expand All @@ -29,15 +29,15 @@ export function useIntersectionObserver<T extends Element>(
ref: Solid.Accessor<T | null>,
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
options: { disabled?: boolean } = {},
disabled?: boolean,
): Solid.Accessor<IntersectionObserver | null> {
const isIntersectionObserverAvailable =
typeof IntersectionObserver === 'function'
let observerRef: IntersectionObserver | null = null

Solid.createEffect(() => {
const r = ref()
if (!r || !isIntersectionObserverAvailable || options.disabled) {
if (!r || !isIntersectionObserverAvailable || disabled) {
return
}

Expand Down
2 changes: 1 addition & 1 deletion packages/vue-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ export function useLinkProps<
ref,
preloadViewportIoCallback,
{ rootMargin: '100px' },
{ disabled: () => !!options.disabled || !(preload.value === 'viewport') },
() => !!options.disabled || preload.value !== 'viewport',
)

Vue.effect(() => {
Expand Down
13 changes: 4 additions & 9 deletions packages/vue-router/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const usePrevious = (fn: () => boolean) => {
*
* @param ref - The ref to observe
* @param intersectionObserverOptions - The options to pass to the IntersectionObserver
* @param options - The options to pass to the hook
* @param disabled - Whether observation is disabled
* @param callback - The callback to call when the intersection changes
* @returns The IntersectionObserver instance
* @example
Expand All @@ -43,7 +43,7 @@ export const usePrevious = (fn: () => boolean) => {
* ref,
* (entry) => { doSomething(entry) },
* { rootMargin: '10px' },
* { disabled: false }
* () => false
* )
* return <div ref={ref} />
* ```
Expand All @@ -52,7 +52,7 @@ export function useIntersectionObserver<T extends Element>(
ref: Vue.Ref<T | null>,
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
options: { disabled?: boolean | (() => boolean) } = {},
disabled: () => boolean,
): Vue.Ref<IntersectionObserver | null> {
const isIntersectionObserverAvailable =
typeof IntersectionObserver === 'function'
Expand All @@ -61,12 +61,7 @@ export function useIntersectionObserver<T extends Element>(
// Use watchEffect with cleanup to properly manage the observer lifecycle
Vue.watchEffect((onCleanup) => {
const r = ref.value
// Support both static boolean and function for disabled check
const isDisabled =
typeof options.disabled === 'function'
? options.disabled()
: options.disabled
if (!r || !isIntersectionObserverAvailable || isDisabled) {
if (!r || !isIntersectionObserverAvailable || disabled()) {
return
}

Expand Down
65 changes: 65 additions & 0 deletions packages/vue-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
useRouteContext,
useSearch,
} from '../src'
import { useIntersectionObserver } from '../src/utils'
import {
getIntersectionObserverMock,
getSearchParamsFromURI,
Expand Down Expand Up @@ -5053,6 +5054,70 @@ describe('Link', () => {
expect(ioDisconnectMock).not.toHaveBeenCalled() // it should not disconnect again
})

test('Link.disabled should disable viewport observation', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<h1>Index Heading</h1>
<Link to="/" disabled>
Index Link
</Link>
</>
),
})

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

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

const indexLink = await screen.findByRole('link', { name: 'Index Link' })
expect(indexLink).toBeInTheDocument()
expect(indexLink).toHaveAttribute('aria-disabled', 'true')
expect(ioObserveMock).not.toHaveBeenCalled()
})

test('useIntersectionObserver should react to its disabled getter', async () => {
const TestComponent = Vue.defineComponent({
name: 'TestComponent',
setup() {
const element = Vue.ref<HTMLElement | null>(null)
const disabled = Vue.ref(true)

useIntersectionObserver(
element,
() => {},
{},
() => disabled.value,
)

return () => (
<>
<button onClick={() => (disabled.value = !disabled.value)}>
Toggle disabled
</button>
<div ref={element} />
</>
)
},
})

render(<TestComponent />)
expect(ioObserveMock).not.toHaveBeenCalled()

const toggle = screen.getByRole('button', { name: 'Toggle disabled' })
await fireEvent.click(toggle)
await waitFor(() => expect(ioObserveMock).toHaveBeenCalledOnce())

await fireEvent.click(toggle)
await waitFor(() => expect(ioDisconnectMock).toHaveBeenCalledOnce())
})

test("Router.preload='render', should trigger the route loader on render", async () => {
const mock = vi.fn()

Expand Down