Skip to content

Commit 4b59a7f

Browse files
authored
Reapply "[image, link] fix ref merging for callback refs..." (#68176) (#68199)
This reverts commit 7f677d1. Cannot reproduce the `vercel-site` failure cited in #68176, and there's no apparent reason why this PR would cause that
1 parent 6fe66a0 commit 4b59a7f

File tree

7 files changed

+218
-61
lines changed

7 files changed

+218
-61
lines changed

packages/next/src/client/image-component.tsx

Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { RouterContext } from '../shared/lib/router-context.shared-runtime'
3131

3232
// @ts-ignore - This is replaced by webpack alias
3333
import defaultLoader from 'next/dist/shared/lib/image-loader'
34+
import { useMergedRef } from './use-merged-ref'
3435

3536
// This is replaced by webpack define plugin
3637
const configEnv = process.env.__NEXT_IMAGE_OPTS as any as ImageConfigComplete
@@ -206,6 +207,54 @@ const ImageElement = forwardRef<HTMLImageElement | null, ImageElementProps>(
206207
},
207208
forwardedRef
208209
) => {
210+
const ownRef = useCallback(
211+
(img: ImgElementWithDataProp | null) => {
212+
if (!img) {
213+
return
214+
}
215+
if (onError) {
216+
// If the image has an error before react hydrates, then the error is lost.
217+
// The workaround is to wait until the image is mounted which is after hydration,
218+
// then we set the src again to trigger the error handler (if there was an error).
219+
// eslint-disable-next-line no-self-assign
220+
img.src = img.src
221+
}
222+
if (process.env.NODE_ENV !== 'production') {
223+
if (!src) {
224+
console.error(`Image is missing required "src" property:`, img)
225+
}
226+
if (img.getAttribute('alt') === null) {
227+
console.error(
228+
`Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`
229+
)
230+
}
231+
}
232+
if (img.complete) {
233+
handleLoading(
234+
img,
235+
placeholder,
236+
onLoadRef,
237+
onLoadingCompleteRef,
238+
setBlurComplete,
239+
unoptimized,
240+
sizesInput
241+
)
242+
}
243+
},
244+
[
245+
src,
246+
placeholder,
247+
onLoadRef,
248+
onLoadingCompleteRef,
249+
setBlurComplete,
250+
onError,
251+
unoptimized,
252+
sizesInput,
253+
]
254+
)
255+
256+
const ref = useMergedRef(forwardedRef, ownRef)
257+
209258
return (
210259
<img
211260
{...rest}
@@ -229,59 +278,7 @@ const ImageElement = forwardRef<HTMLImageElement | null, ImageElementProps>(
229278
sizes={sizes}
230279
srcSet={srcSet}
231280
src={src}
232-
ref={useCallback(
233-
(img: ImgElementWithDataProp | null) => {
234-
if (forwardedRef) {
235-
if (typeof forwardedRef === 'function') forwardedRef(img)
236-
else if (typeof forwardedRef === 'object') {
237-
// @ts-ignore - .current is read only it's usually assigned by react internally
238-
forwardedRef.current = img
239-
}
240-
}
241-
if (!img) {
242-
return
243-
}
244-
if (onError) {
245-
// If the image has an error before react hydrates, then the error is lost.
246-
// The workaround is to wait until the image is mounted which is after hydration,
247-
// then we set the src again to trigger the error handler (if there was an error).
248-
// eslint-disable-next-line no-self-assign
249-
img.src = img.src
250-
}
251-
if (process.env.NODE_ENV !== 'production') {
252-
if (!src) {
253-
console.error(`Image is missing required "src" property:`, img)
254-
}
255-
if (img.getAttribute('alt') === null) {
256-
console.error(
257-
`Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`
258-
)
259-
}
260-
}
261-
if (img.complete) {
262-
handleLoading(
263-
img,
264-
placeholder,
265-
onLoadRef,
266-
onLoadingCompleteRef,
267-
setBlurComplete,
268-
unoptimized,
269-
sizesInput
270-
)
271-
}
272-
},
273-
[
274-
src,
275-
placeholder,
276-
onLoadRef,
277-
onLoadingCompleteRef,
278-
setBlurComplete,
279-
onError,
280-
unoptimized,
281-
sizesInput,
282-
forwardedRef,
283-
]
284-
)}
281+
ref={ref}
285282
onLoad={(event) => {
286283
const img = event.currentTarget as ImgElementWithDataProp
287284
handleLoading(

packages/next/src/client/link.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useIntersection } from './use-intersection'
2222
import { getDomainLocale } from './get-domain-locale'
2323
import { addBasePath } from './add-base-path'
2424
import { PrefetchKind } from './components/router-reducer/router-reducer-types'
25+
import { useMergedRef } from './use-merged-ref'
2526

2627
type Url = string | UrlObject
2728
type RequiredKeys<T> = {
@@ -546,7 +547,7 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
546547
rootMargin: '200px',
547548
})
548549

549-
const setRef = React.useCallback(
550+
const setIntersectionWithResetRef = React.useCallback(
550551
(el: Element) => {
551552
// Before the link getting observed, check if visible state need to be reset
552553
if (previousAs.current !== as || previousHref.current !== href) {
@@ -556,16 +557,12 @@ const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
556557
}
557558

558559
setIntersectionRef(el)
559-
if (childRef) {
560-
if (typeof childRef === 'function') childRef(el)
561-
else if (typeof childRef === 'object') {
562-
childRef.current = el
563-
}
564-
}
565560
},
566-
[as, childRef, href, resetVisible, setIntersectionRef]
561+
[as, href, resetVisible, setIntersectionRef]
567562
)
568563

564+
const setRef = useMergedRef(setIntersectionWithResetRef, childRef)
565+
569566
// Prefetch the URL if we haven't already and it's visible.
570567
React.useEffect(() => {
571568
// in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useMemo, type Ref } from 'react'
2+
3+
export function useMergedRef<TElement>(
4+
refA: Ref<TElement>,
5+
refB: Ref<TElement>
6+
): Ref<TElement> {
7+
return useMemo(() => mergeRefs(refA, refB), [refA, refB])
8+
}
9+
10+
export function mergeRefs<TElement>(
11+
refA: Ref<TElement>,
12+
refB: Ref<TElement>
13+
): Ref<TElement> {
14+
if (!refA || !refB) {
15+
return refA || refB
16+
}
17+
18+
return (current: TElement) => {
19+
const cleanupA = applyRef(refA, current)
20+
const cleanupB = applyRef(refB, current)
21+
22+
return () => {
23+
cleanupA()
24+
cleanupB()
25+
}
26+
}
27+
}
28+
29+
function applyRef<TElement>(
30+
refA: NonNullable<Ref<TElement>>,
31+
current: TElement
32+
) {
33+
if (typeof refA === 'function') {
34+
const cleanup = refA(current)
35+
if (typeof cleanup === 'function') {
36+
return cleanup
37+
} else {
38+
return () => refA(null)
39+
}
40+
} else {
41+
refA.current = current
42+
return () => {
43+
refA.current = null
44+
}
45+
}
46+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from 'react'
2+
import Link from 'next/link'
3+
import { useCallback, useRef, useEffect, useState } from 'react'
4+
import { flushSync } from 'react-dom'
5+
6+
export default function Page() {
7+
const [isVisible, setIsVisible] = useState(true)
8+
9+
const statusRef = useRef({ wasInitialized: false, wasCleanedUp: false })
10+
11+
const refWithCleanup = useCallback((el) => {
12+
if (!el) {
13+
console.error(
14+
'callback refs that returned a cleanup should never be called with null'
15+
)
16+
return
17+
}
18+
19+
statusRef.current.wasInitialized = true
20+
return () => {
21+
statusRef.current.wasCleanedUp = true
22+
}
23+
}, [])
24+
25+
useEffect(() => {
26+
const timeout = setTimeout(
27+
() => {
28+
flushSync(() => {
29+
setIsVisible(false)
30+
})
31+
if (!statusRef.current.wasInitialized) {
32+
console.error('callback ref was not initialized')
33+
}
34+
if (!statusRef.current.wasCleanedUp) {
35+
console.error('callback ref was not cleaned up')
36+
}
37+
},
38+
100 // if we hide the Link too quickly, the prefetch won't fire, failing a test
39+
)
40+
return () => clearTimeout(timeout)
41+
}, [])
42+
43+
if (!isVisible) {
44+
return null
45+
}
46+
47+
return (
48+
<Link href="/" ref={refWithCleanup}>
49+
Click me
50+
</Link>
51+
)
52+
}

test/integration/link-ref/test/index.test.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ describe('Invalid hrefs', () => {
8484
it('should handle child ref that is a function', async () => {
8585
await noError('/child-ref-func')
8686
})
87+
88+
it('should handle child ref that is a function that returns a cleanup function', async () => {
89+
await noError('/child-ref-func-cleanup')
90+
})
8791
}
8892
)
8993
;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
@@ -109,6 +113,10 @@ describe('Invalid hrefs', () => {
109113
it('should preload with child ref with function', async () => {
110114
await didPrefetch('/child-ref-func')
111115
})
116+
117+
it('should preload with child ref with function that returns a cleanup function', async () => {
118+
await didPrefetch('/child-ref-func-cleanup')
119+
})
112120
}
113121
)
114122
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client'
2+
import Image from 'next/image'
3+
import { useCallback, useEffect, useState } from 'react'
4+
5+
export default function Home() {
6+
const [displayImage, setDisplayImage] = useState(true)
7+
8+
const refWithCleanup = useCallback((el) => {
9+
if (!el) {
10+
throw new Error(
11+
'callback refs that returned a cleanup should never be called with null'
12+
)
13+
}
14+
15+
return () => {
16+
console.log('callback ref was cleaned up')
17+
}
18+
}, [])
19+
20+
useEffect(() => {
21+
setDisplayImage(false)
22+
}, [])
23+
24+
return (
25+
<main>
26+
<h1>Should call ref cleanup on unmount</h1>
27+
<section>
28+
{displayImage ? (
29+
<div style={{ position: 'relative', width: 10, height: 10 }}>
30+
<Image
31+
ref={refWithCleanup}
32+
priority
33+
fill
34+
src="/test.jpg"
35+
alt="alt"
36+
sizes="10px"
37+
/>
38+
</div>
39+
) : null}
40+
</section>
41+
</main>
42+
)
43+
}

test/integration/next-image-new/app-dir/test/index.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
nextBuild,
1515
nextStart,
1616
renderViaHTTP,
17+
retry,
1718
waitFor,
1819
} from 'next-test-utils'
1920
import webdriver from 'next-webdriver'
@@ -1574,6 +1575,19 @@ function runTests(mode) {
15741575
}
15751576
}
15761577
})
1578+
1579+
it('should call callback ref cleanups when unmounting', async () => {
1580+
const browser = await webdriver(appPort, '/ref-cleanup')
1581+
const getLogs = async () => (await browser.log()).map((log) => log.message)
1582+
1583+
await retry(async () => {
1584+
expect(await getLogs()).toContain('callback ref was cleaned up')
1585+
})
1586+
1587+
expect(await getLogs()).not.toContain(
1588+
'callback refs that returned a cleanup should never be called with null'
1589+
)
1590+
})
15771591
}
15781592

15791593
describe('Image Component Default Tests', () => {

0 commit comments

Comments
 (0)