Skip to content

Commit

Permalink
Run link-ref tests in /app and /pages (#69564)
Browse files Browse the repository at this point in the history
We need to handle ref cleanups in a way that is compatible with React 18 and 19.

Forking these into -pages (18) and -app (19).
Combining them into a single app lead to a different behavior with regards to preload.
  • Loading branch information
eps1lon authored Sep 4, 2024
1 parent caa72ff commit e9cbf27
Show file tree
Hide file tree
Showing 17 changed files with 323 additions and 0 deletions.
53 changes: 53 additions & 0 deletions test/integration/link-ref-app/app/child-ref-func-cleanup/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { useCallback, useRef, useEffect, useState } from 'react'
import { flushSync } from 'react-dom'

export default function Page() {
const [isVisible, setIsVisible] = useState(true)

const statusRef = useRef({ wasInitialized: false, wasCleanedUp: false })

const refWithCleanup = useCallback((el) => {
if (!el) {
console.error(
'callback refs that returned a cleanup should never be called with null'
)
return
}

statusRef.current.wasInitialized = true
return () => {
statusRef.current.wasCleanedUp = true
}
}, [])

useEffect(() => {
const timeout = setTimeout(
() => {
flushSync(() => {
setIsVisible(false)
})
if (!statusRef.current.wasInitialized) {
console.error('callback ref was not initialized')
}
if (!statusRef.current.wasCleanedUp) {
console.error('callback ref was not cleaned up')
}
},
100 // if we hide the Link too quickly, the prefetch won't fire, failing a test
)
return () => clearTimeout(timeout)
}, [])

if (!isVisible) {
return null
}

return (
<Link href="/" ref={refWithCleanup}>
Click me
</Link>
)
}
24 changes: 24 additions & 0 deletions test/integration/link-ref-app/app/child-ref-func/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client'
import React from 'react'
import Link from 'next/link'

export default () => {
const myRef = React.createRef(null)

React.useEffect(() => {
if (!myRef.current) {
console.error(`ref wasn't updated`)
}
})

return (
<Link
href="/"
ref={(el) => {
myRef.current = el
}}
>
Click me
</Link>
)
}
19 changes: 19 additions & 0 deletions test/integration/link-ref-app/app/child-ref/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'
import React from 'react'
import Link from 'next/link'

export default () => {
const myRef = React.createRef(null)

React.useEffect(() => {
if (!myRef.current) {
console.error(`ref wasn't updated`)
}
})

return (
<Link href="/" ref={myRef}>
Click me
</Link>
)
}
15 changes: 15 additions & 0 deletions test/integration/link-ref-app/app/class/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'
import React from 'react'
import Link from 'next/link'

class MyLink extends React.Component {
render() {
return <a {...this.props}>Click me</a>
}
}

export default () => (
<Link href="/" passHref legacyBehavior>
<MyLink />
</Link>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'

const useClickAway = (ref, onClickAway) => {
useEffect(() => {
const handler = (event) => {
const el = ref.current

// when menu is open and clicked inside menu, A is expected to be false
// when menu is open and clicked outside menu, A is expected to be true
console.log('A', el && !el.contains(event.target))

el && !el.contains(event.target) && onClickAway(event)
}

let timeoutID = setTimeout(() => {
timeoutID = null
document.addEventListener('click', handler)
}, 0)

return () => {
if (timeoutID != null) {
clearTimeout(timeoutID)
}
document.removeEventListener('click', handler)
}
}, [onClickAway, ref])
}

export default function App() {
const [open, setOpen] = useState(false)

const menuRef = useRef(null)

const onClickAway = useCallback(() => {
console.log('click away, open', open)
if (open) {
setOpen(false)
}
}, [open])

useClickAway(menuRef, onClickAway)

return (
<div>
<div id="click-me" onClick={() => setOpen(true)}>
Open Menu
</div>
{open && (
<div ref={menuRef} id="the-menu">
<Link href="/">some link</Link>
</div>
)}
</div>
)
}
15 changes: 15 additions & 0 deletions test/integration/link-ref-app/app/function/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'
import React from 'react'
import Link from 'next/link'

const MyLink = React.forwardRef((props, ref) => (
<a {...props} ref={ref}>
Click me
</a>
))

export default () => (
<Link href="/" passHref legacyBehavior>
<MyLink />
</Link>
)
7 changes: 7 additions & 0 deletions test/integration/link-ref-app/app/layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
File renamed without changes.
132 changes: 132 additions & 0 deletions test/integration/link-ref-app/test/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* eslint-env jest */

import { join } from 'path'
import webdriver from 'next-webdriver'
import {
retry,
findPort,
launchApp,
killApp,
nextStart,
nextBuild,
waitFor,
} from 'next-test-utils'

let app
let appPort
const appDir = join(__dirname, '..')

const noError = async (pathname) => {
const browser = await webdriver(appPort, '/')
await browser.eval(`(function() {
window.caughtErrors = []
const origError = window.console.error
window.console.error = function (format) {
window.caughtErrors.push(format)
origError(arguments)
}
window.next.router.replace('${pathname}')
})()`)
await waitFor(1000)
const errors = await browser.eval(`window.caughtErrors`)
expect(errors).toEqual([])
await browser.close()
}

const didPrefetch = async (pathname) => {
const requests = []
const browser = await webdriver(appPort, pathname, {
beforePageLoad(page) {
page.on('request', async (req) => {
const url = new URL(req.url())
const headers = await req.allHeaders()
if (headers['next-router-prefetch']) {
requests.push(url.pathname)
}
})
},
})

await browser.waitForIdleNetwork()

await retry(async () => {
expect(requests).toEqual(
expect.arrayContaining([expect.stringContaining('/')])
)
})

await browser.close()
}

function runCommonTests() {
// See https://github.com/vercel/next.js/issues/18437
it('should not have a race condition with a click handler', async () => {
const browser = await webdriver(appPort, '/click-away-race-condition')
await browser.elementByCss('#click-me').click()
await browser.waitForElementByCss('#the-menu')
})
}

describe('Invalid hrefs', () => {
;(process.env.TURBOPACK_BUILD ? describe.skip : describe)(
'development mode',
() => {
beforeAll(async () => {
appPort = await findPort()
app = await launchApp(appDir, appPort)
})
afterAll(() => killApp(app))

runCommonTests()

it('should not show error for function component with forwardRef', async () => {
await noError('/function')
})

it('should not show error for class component as child of next/link', async () => {
await noError('/class')
})

it('should handle child ref with React.createRef', async () => {
await noError('/child-ref')
})

it('should handle child ref that is a function', async () => {
await noError('/child-ref-func')
})

it('should handle child ref that is a function that returns a cleanup function', async () => {
await noError('/child-ref-func-cleanup')
})
}
)
;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
'production mode',
() => {
beforeAll(async () => {
await nextBuild(appDir)
appPort = await findPort()
app = await nextStart(appDir, appPort)
})
afterAll(() => killApp(app))

runCommonTests()

it('should preload with forwardRef', async () => {
await didPrefetch('/function')
})

it('should preload with child ref with React.createRef', async () => {
await didPrefetch('/child-ref')
})

it('should preload with child ref with function', async () => {
await didPrefetch('/child-ref-func')
})

it('should preload with child ref with function that returns a cleanup function', async () => {
await didPrefetch('/child-ref-func-cleanup')
})
}
)
})
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions test/integration/link-ref-pages/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default () => 'hi'

0 comments on commit e9cbf27

Please sign in to comment.