Compare commits

...

2 Commits

Author SHA1 Message Date
Jingyi-Dify
9748fea0ea Merge remote-tracking branch 'origin/main' into codex/integrations-intent-prefetch 2026-08-07 11:13:19 -07:00
Jingyi-Dify
cd38e9f520 perf(web): prefetch integration links on intent 2026-08-07 11:13:05 -07:00
7 changed files with 125 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import type { AnchorHTMLAttributes, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IntegrationSidebarNavItem } from '../sidebar-nav-item'
vi.mock('@/next/link', () => ({
default: ({
children,
href,
prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
children?: ReactNode
href: string
prefetch?: boolean | null
}) => (
<a href={href} data-prefetch={prefetch === null ? 'auto' : prefetch} {...props}>
{children}
</a>
),
}))
const item = {
icon: 'i-ri-database-2-line',
label: 'Data source',
section: 'data-source' as const,
}
describe('IntegrationSidebarNavItem', () => {
it('enables route prefetch after pointer intent', async () => {
const user = userEvent.setup()
render(<IntegrationSidebarNavItem item={item} section="provider" />)
const link = screen.getByRole('link', { name: 'Data source' })
expect(link).toHaveAttribute('data-prefetch', 'false')
await user.hover(link)
expect(link).toHaveAttribute('data-prefetch', 'auto')
})
it('enables route prefetch after keyboard focus', async () => {
const user = userEvent.setup()
render(<IntegrationSidebarNavItem item={item} section="provider" />)
const link = screen.getByRole('link', { name: 'Data source' })
expect(link).toHaveAttribute('data-prefetch', 'false')
await user.tab()
expect(link).toHaveFocus()
expect(link).toHaveAttribute('data-prefetch', 'auto')
})
})

View File

@@ -3,6 +3,7 @@
import type { ComponentType } from 'react'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import Link from '@/next/link'
import { buildIntegrationPath } from './routes'
import {
@@ -55,6 +56,7 @@ export function IntegrationSidebarNavItem({
onSelect,
section,
}: IntegrationSidebarNavItemProps) {
const [isPrefetchEnabled, setIsPrefetchEnabled] = useState(false)
const isActive = item.section === section
const icon = isActive && item.activeIcon ? item.activeIcon : item.icon
@@ -119,6 +121,9 @@ export function IntegrationSidebarNavItem({
return (
<Link
href={buildIntegrationPath(item.section)}
prefetch={isPrefetchEnabled ? null : false}
onMouseEnter={() => setIsPrefetchEnabled(true)}
onFocus={() => setIsPrefetchEnabled(true)}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={className}

View File

@@ -0,0 +1,57 @@
import type { AnchorHTMLAttributes, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import MainNavLink from '../nav-link'
vi.mock('@/next/link', () => ({
default: ({
children,
href,
prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
children?: ReactNode
href: string
prefetch?: boolean | null
}) => (
<a href={href} data-prefetch={prefetch === null ? 'auto' : prefetch} {...props}>
{children}
</a>
),
}))
const integrationsItem = {
href: '/integrations/model-provider',
label: 'Integrations',
active: () => false,
icon: 'i-ri-plug-line',
activeIcon: 'i-ri-plug-fill',
prefetchOnIntent: true,
}
describe('MainNavLink', () => {
it('enables Integration route prefetch after pointer intent', async () => {
const user = userEvent.setup()
render(<MainNavLink item={integrationsItem} pathname="/apps" />)
const link = screen.getByRole('link', { name: 'Integrations' })
expect(link).toHaveAttribute('data-prefetch', 'false')
await user.hover(link)
expect(link).toHaveAttribute('data-prefetch', 'auto')
})
it('enables Integration route prefetch after keyboard focus', async () => {
const user = userEvent.setup()
render(<MainNavLink item={integrationsItem} pathname="/apps" />)
const link = screen.getByRole('link', { name: 'Integrations' })
expect(link).toHaveAttribute('data-prefetch', 'false')
await user.tab()
expect(link).toHaveFocus()
expect(link).toHaveAttribute('data-prefetch', 'auto')
})
})

View File

@@ -3,6 +3,7 @@
import type { ReactNode } from 'react'
import type { MainNavItem } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { useState } from 'react'
import Link from '@/next/link'
const NavIcon = ({ icon, className }: { icon: string; className?: string }) => (
@@ -16,11 +17,15 @@ type MainNavLinkProps = {
}
const MainNavLink = ({ item, pathname, children }: MainNavLinkProps) => {
const [isPrefetchEnabled, setIsPrefetchEnabled] = useState(false)
const activated = item.active(pathname)
return (
<Link
href={item.href}
prefetch={item.prefetchOnIntent ? (isPrefetchEnabled ? null : false) : undefined}
onMouseEnter={item.prefetchOnIntent ? () => setIsPrefetchEnabled(true) : undefined}
onFocus={item.prefetchOnIntent ? () => setIsPrefetchEnabled(true) : undefined}
aria-current={activated ? 'page' : undefined}
title={item.label}
className={cn(

View File

@@ -60,6 +60,7 @@ export function MainNav({ className }: MainNavProps) {
active: route.active,
icon: route.icon,
activeIcon: route.activeIcon,
prefetchOnIntent: 'prefetchOnIntent' in route && route.prefetchOnIntent,
})),
[
agentV2Enabled,

View File

@@ -14,6 +14,7 @@ export type MainNavRouteConfig = {
activeIcon: string
visibility: MainNavRouteVisibility
feature?: 'agentV2' | 'marketplace'
prefetchOnIntent?: boolean
} & ({ label: string; labelKey?: never } | { label?: never; labelKey: string })
export type MainNavRouteVisibilityOptions = {
@@ -87,6 +88,7 @@ export const MAIN_NAV_ROUTES = [
icon: 'i-custom-vender-main-nav-integrations',
activeIcon: 'i-custom-vender-main-nav-integrations-active',
visibility: VISIBLE_TO_ALL,
prefetchOnIntent: true,
},
{
key: 'marketplace',

View File

@@ -8,4 +8,5 @@ export type MainNavItem = {
active: (pathname: string) => boolean
icon: string
activeIcon: string
prefetchOnIntent?: boolean
}