Skip to content

Commit c3ca17c

Browse files
authored
fix: add missing <preload> for next/image in App Router (#52425)
- Depends on facebook/react#27096 - Fixes #43134 - Closes NEXT-811 - Closes NEXT-846
1 parent d10f105 commit c3ca17c

File tree

6 files changed

+140
-77
lines changed

6 files changed

+140
-77
lines changed

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

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React, {
1010
forwardRef,
1111
version,
1212
} from 'react'
13+
import { preload } from 'react-dom'
1314
import Head from '../shared/lib/head'
1415
import { getImgProps } from '../shared/lib/get-img-props'
1516
import type {
@@ -26,6 +27,7 @@ import type {
2627
import { imageConfigDefault } from '../shared/lib/image-config'
2728
import { ImageConfigContext } from '../shared/lib/image-config-context'
2829
import { warnOnce } from '../shared/lib/utils/warn-once'
30+
import { RouterContext } from '../shared/lib/router-context'
2931

3032
// @ts-ignore - This is replaced by webpack alias
3133
import defaultLoader from 'next/dist/shared/lib/image-loader'
@@ -302,8 +304,60 @@ const ImageElement = forwardRef<HTMLImageElement | null, ImageElementProps>(
302304
}
303305
)
304306

307+
function ImagePreload({
308+
isAppRouter,
309+
imgAttributes,
310+
}: {
311+
isAppRouter: boolean
312+
imgAttributes: ImgProps
313+
}) {
314+
const opts = {
315+
as: 'image',
316+
imageSrcSet: imgAttributes.srcSet,
317+
imageSizes: imgAttributes.sizes,
318+
crossOrigin: imgAttributes.crossOrigin,
319+
referrerPolicy: imgAttributes.referrerPolicy,
320+
...getDynamicProps(imgAttributes.fetchPriority),
321+
}
322+
323+
if (isAppRouter) {
324+
// See https://github.com/facebook/react/pull/26940
325+
preload(
326+
imgAttributes.src,
327+
// @ts-expect-error TODO: upgrade to `@types/react-dom@18.3.x`
328+
opts
329+
)
330+
return null
331+
}
332+
333+
return (
334+
<Head>
335+
<link
336+
key={
337+
'__nimg-' +
338+
imgAttributes.src +
339+
imgAttributes.srcSet +
340+
imgAttributes.sizes
341+
}
342+
rel="preload"
343+
// Note how we omit the `href` attribute, as it would only be relevant
344+
// for browsers that do not support `imagesrcset`, and in those cases
345+
// it would cause the incorrect image to be preloaded.
346+
//
347+
// https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset
348+
href={imgAttributes.srcSet ? undefined : imgAttributes.src}
349+
{...opts}
350+
/>
351+
</Head>
352+
)
353+
}
354+
305355
export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
306356
(props, forwardedRef) => {
357+
const pagesRouter = useContext(RouterContext)
358+
// We're in the app directory if there is no pages router.
359+
const isAppRouter = !pagesRouter
360+
307361
const configContext = useContext(ImageConfigContext)
308362
const config = useMemo(() => {
309363
const c = configEnv || configContext || imageConfigDefault
@@ -351,29 +405,10 @@ export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
351405
/>
352406
}
353407
{imgMeta.priority ? (
354-
// Note how we omit the `href` attribute, as it would only be relevant
355-
// for browsers that do not support `imagesrcset`, and in those cases
356-
// it would likely cause the incorrect image to be preloaded.
357-
//
358-
// https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset
359-
<Head>
360-
<link
361-
key={
362-
'__nimg-' +
363-
imgAttributes.src +
364-
imgAttributes.srcSet +
365-
imgAttributes.sizes
366-
}
367-
rel="preload"
368-
as="image"
369-
href={imgAttributes.srcSet ? undefined : imgAttributes.src}
370-
imageSrcSet={imgAttributes.srcSet}
371-
imageSizes={imgAttributes.sizes}
372-
crossOrigin={imgAttributes.crossOrigin}
373-
referrerPolicy={imgAttributes.referrerPolicy}
374-
{...getDynamicProps(imgAttributes.fetchPriority)}
375-
/>
376-
</Head>
408+
<ImagePreload
409+
isAppRouter={isAppRouter}
410+
imgAttributes={imgAttributes}
411+
/>
377412
) : null}
378413
</>
379414
)

test/integration/next-image-new/app-dir/app/priority/page.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ const Page = () => {
1717
priority
1818
id="basic-image-crossorigin"
1919
alt="basic-image-crossorigin"
20-
src="/test.jpg"
20+
src="/test.webp"
2121
width="400"
2222
height="400"
23-
crossOrigin="anonymous"
23+
crossOrigin="use-credentials"
2424
></Image>
2525
<Image
2626
priority
@@ -36,8 +36,8 @@ const Page = () => {
3636
id="load-eager"
3737
alt="load-eager"
3838
src="/test.png"
39-
width="400"
40-
height="400"
39+
width="200"
40+
height="200"
4141
></Image>
4242
<Image
4343
priority

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

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,7 @@ function runTests(mode) {
101101
}
102102
})
103103

104-
// TODO: need to add <link preload> to app dir
105-
it.skip('should preload priority images', async () => {
104+
it('should preload priority images', async () => {
106105
let browser
107106
try {
108107
browser = await webdriver(appPort, '/priority')
@@ -125,20 +124,30 @@ function runTests(mode) {
125124
const fetchpriority = await link.getAttribute('fetchpriority')
126125
const imagesrcset = await link.getAttribute('imagesrcset')
127126
const imagesizes = await link.getAttribute('imagesizes')
128-
entries.push({ fetchpriority, imagesrcset, imagesizes })
127+
const crossorigin = await link.getAttribute('crossorigin')
128+
const referrerpolicy = await link.getAttribute('referrerPolicy')
129+
entries.push({
130+
fetchpriority,
131+
imagesrcset,
132+
imagesizes,
133+
crossorigin,
134+
referrerpolicy,
135+
})
129136
}
130137

131138
expect(
132139
entries.find(
133140
(item) =>
134141
item.imagesrcset ===
135-
'/_next/image?url=%2Ftest.jpg&w=640&q=75 1x, /_next/image?url=%2Ftest.jpg&w=828&q=75 2x'
142+
'/_next/image?url=%2Ftest.webp&w=640&q=75 1x, /_next/image?url=%2Ftest.webp&w=828&q=75 2x'
136143
)
137144
).toEqual({
138145
fetchpriority: 'high',
139146
imagesizes: '',
140147
imagesrcset:
141-
'/_next/image?url=%2Ftest.jpg&w=640&q=75 1x, /_next/image?url=%2Ftest.jpg&w=828&q=75 2x',
148+
'/_next/image?url=%2Ftest.webp&w=640&q=75 1x, /_next/image?url=%2Ftest.webp&w=828&q=75 2x',
149+
crossorigin: 'use-credentials',
150+
referrerpolicy: '',
142151
})
143152

144153
expect(
@@ -152,6 +161,23 @@ function runTests(mode) {
152161
imagesizes: '100vw',
153162
imagesrcset:
154163
'/_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w',
164+
crossorigin: '',
165+
referrerpolicy: '',
166+
})
167+
168+
expect(
169+
entries.find(
170+
(item) =>
171+
item.imagesrcset ===
172+
'/_next/image?url=%2Ftest.png&w=640&q=75 1x, /_next/image?url=%2Ftest.png&w=828&q=75 2x'
173+
)
174+
).toEqual({
175+
fetchpriority: 'high',
176+
imagesizes: '',
177+
imagesrcset:
178+
'/_next/image?url=%2Ftest.png&w=640&q=75 1x, /_next/image?url=%2Ftest.png&w=828&q=75 2x',
179+
crossorigin: '',
180+
referrerpolicy: 'no-referrer',
155181
})
156182

157183
// When priority={true}, we should _not_ set loading="lazy"
@@ -197,20 +223,6 @@ function runTests(mode) {
197223
/was detected as the Largest Contentful Paint/gm
198224
)
199225
expect(warnings).not.toMatch(/React does not recognize the (.+) prop/gm)
200-
201-
// should preload with crossorigin
202-
expect(
203-
await browser.elementsByCss(
204-
'link[rel=preload][as=image][crossorigin=anonymous][imagesrcset*="test.jpg"]'
205-
)
206-
).toHaveLength(1)
207-
208-
// should preload with referrerpolicy
209-
expect(
210-
await browser.elementsByCss(
211-
'link[rel=preload][as=image][referrerpolicy="no-referrer"][imagesrcset*="test.png"]'
212-
)
213-
).toHaveLength(1)
214226
} finally {
215227
if (browser) {
216228
await browser.close()

test/integration/next-image-new/default/pages/priority.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ const Page = () => {
1717
priority
1818
id="basic-image-crossorigin"
1919
alt="basic-image-crossorigin"
20-
src="/test.jpg"
20+
src="/test.webp"
2121
width="400"
2222
height="400"
23-
crossOrigin="anonymous"
23+
crossOrigin="use-credentials"
2424
></Image>
2525
<Image
2626
priority
@@ -36,8 +36,8 @@ const Page = () => {
3636
id="load-eager"
3737
alt="load-eager"
3838
src="/test.png"
39-
width="400"
40-
height="400"
39+
width="200"
40+
height="200"
4141
></Image>
4242
<Image
4343
priority

test/integration/next-image-new/default/test/index.test.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,30 @@ function runTests(mode) {
125125
const fetchpriority = await link.getAttribute('fetchpriority')
126126
const imagesrcset = await link.getAttribute('imagesrcset')
127127
const imagesizes = await link.getAttribute('imagesizes')
128-
entries.push({ fetchpriority, imagesrcset, imagesizes })
128+
const crossorigin = await link.getAttribute('crossorigin')
129+
const referrerpolicy = await link.getAttribute('referrerPolicy')
130+
entries.push({
131+
fetchpriority,
132+
imagesrcset,
133+
imagesizes,
134+
crossorigin,
135+
referrerpolicy,
136+
})
129137
}
138+
130139
expect(
131140
entries.find(
132141
(item) =>
133142
item.imagesrcset ===
134-
'/_next/image?url=%2Ftest.jpg&w=640&q=75 1x, /_next/image?url=%2Ftest.jpg&w=828&q=75 2x'
143+
'/_next/image?url=%2Ftest.webp&w=640&q=75 1x, /_next/image?url=%2Ftest.webp&w=828&q=75 2x'
135144
)
136145
).toEqual({
137146
fetchpriority: 'high',
138147
imagesizes: '',
139148
imagesrcset:
140-
'/_next/image?url=%2Ftest.jpg&w=640&q=75 1x, /_next/image?url=%2Ftest.jpg&w=828&q=75 2x',
149+
'/_next/image?url=%2Ftest.webp&w=640&q=75 1x, /_next/image?url=%2Ftest.webp&w=828&q=75 2x',
150+
crossorigin: 'use-credentials',
151+
referrerpolicy: '',
141152
})
142153

143154
expect(
@@ -151,6 +162,23 @@ function runTests(mode) {
151162
imagesizes: '100vw',
152163
imagesrcset:
153164
'/_next/image?url=%2Fwide.png&w=640&q=75 640w, /_next/image?url=%2Fwide.png&w=750&q=75 750w, /_next/image?url=%2Fwide.png&w=828&q=75 828w, /_next/image?url=%2Fwide.png&w=1080&q=75 1080w, /_next/image?url=%2Fwide.png&w=1200&q=75 1200w, /_next/image?url=%2Fwide.png&w=1920&q=75 1920w, /_next/image?url=%2Fwide.png&w=2048&q=75 2048w, /_next/image?url=%2Fwide.png&w=3840&q=75 3840w',
165+
crossorigin: '',
166+
referrerpolicy: '',
167+
})
168+
169+
expect(
170+
entries.find(
171+
(item) =>
172+
item.imagesrcset ===
173+
'/_next/image?url=%2Ftest.png&w=640&q=75 1x, /_next/image?url=%2Ftest.png&w=828&q=75 2x'
174+
)
175+
).toEqual({
176+
fetchpriority: 'high',
177+
imagesizes: '',
178+
imagesrcset:
179+
'/_next/image?url=%2Ftest.png&w=640&q=75 1x, /_next/image?url=%2Ftest.png&w=828&q=75 2x',
180+
crossorigin: '',
181+
referrerpolicy: 'no-referrer',
154182
})
155183

156184
// When priority={true}, we should _not_ set loading="lazy"
@@ -196,22 +224,6 @@ function runTests(mode) {
196224
/was detected as the Largest Contentful Paint/gm
197225
)
198226
expect(warnings).not.toMatch(/React does not recognize the (.+) prop/gm)
199-
200-
// should preload with crossorigin
201-
expect(
202-
(
203-
await browser.elementsByCss(
204-
'link[rel=preload][as=image][crossorigin=anonymous][imagesrcset*="test.jpg"]'
205-
)
206-
).length
207-
).toBeGreaterThanOrEqual(1)
208-
209-
// should preload with referrerpolicy
210-
expect(
211-
await browser.elementsByCss(
212-
'link[rel=preload][as=image][referrerpolicy="no-referrer"][imagesrcset*="test.png"]'
213-
)
214-
).toHaveLength(1)
215227
} finally {
216228
if (browser) {
217229
await browser.close()

test/unit/next-image-new.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
/* eslint-env jest */
22
import React from 'react'
3-
import ReactDOM from 'react-dom/server'
3+
import ReactDOMServer from 'react-dom/server'
44
import Image from 'next/image'
55
import cheerio from 'cheerio'
66

7+
// Since this unit test doesn't check the result of
8+
// ReactDOM.preload(), we can turn it into a noop.
9+
jest.mock('react-dom', () => ({ preload: () => null }))
10+
711
describe('Image rendering', () => {
812
it('should render Image on its own', async () => {
913
const element = React.createElement(Image, {
@@ -14,7 +18,7 @@ describe('Image rendering', () => {
1418
height: 100,
1519
loading: 'eager',
1620
})
17-
const html = ReactDOM.renderToString(element)
21+
const html = ReactDOMServer.renderToString(element)
1822
const $ = cheerio.load(html)
1923
const img = $('#unit-image')
2024
// order matters here
@@ -54,9 +58,9 @@ describe('Image rendering', () => {
5458
width: 100,
5559
height: 100,
5660
})
57-
const $ = cheerio.load(ReactDOM.renderToString(element))
58-
const $2 = cheerio.load(ReactDOM.renderToString(element2))
59-
const $lazy = cheerio.load(ReactDOM.renderToString(elementLazy))
61+
const $ = cheerio.load(ReactDOMServer.renderToString(element))
62+
const $2 = cheerio.load(ReactDOMServer.renderToString(element2))
63+
const $lazy = cheerio.load(ReactDOMServer.renderToString(elementLazy))
6064
expect($('noscript').length).toBe(0)
6165
expect($2('noscript').length).toBe(0)
6266
expect($lazy('noscript').length).toBe(0)
@@ -90,9 +94,9 @@ describe('Image rendering', () => {
9094
blurDataURL: 'data:image/png;base64',
9195
priority: true,
9296
})
93-
const $1 = cheerio.load(ReactDOM.renderToString(element1))
94-
const $2 = cheerio.load(ReactDOM.renderToString(element2))
95-
const $3 = cheerio.load(ReactDOM.renderToString(element3))
97+
const $1 = cheerio.load(ReactDOMServer.renderToString(element1))
98+
const $2 = cheerio.load(ReactDOMServer.renderToString(element2))
99+
const $3 = cheerio.load(ReactDOMServer.renderToString(element3))
96100
expect($1('noscript').length).toBe(0)
97101
expect($2('noscript').length).toBe(0)
98102
expect($3('noscript').length).toBe(0)

0 commit comments

Comments
 (0)