Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
16 changes: 16 additions & 0 deletions docs/content/1.get-started/2.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ export default defineNuxtConfig({
})
```

## `preconnect`

By default, Nuxt Image adds a [`<link rel="preconnect">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preconnect) to the page head for the unique origins of the images rendered on the server. This lets the browser start the DNS, TCP and TLS handshake with your image provider before the images are parsed.

Only external `http(s)` origins are preconnected — relative and same-origin images (for example the default `ipx` provider) are ignored, and multiple images from the same origin produce a single `link` tag. The origin of an image's `placeholder` is preconnected as well, as placeholders are typically visible before the main image.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Set `preconnect` to `false` to disable this behavior.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
image: {
preconnect: false
}
})
```

## `quality`

The quality for the generated image(s).
Expand Down
2 changes: 2 additions & 0 deletions docs/content/2.usage/1.nuxt-img.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ Accepted values are `auto`, `high`, or `low`.
<NuxtImg src="/nuxt-icon.png" :preload="{ fetchPriority: 'high' }" />
```

**Note:** Preconnecting to external image origins happens automatically during server rendering. See the [`preconnect` configuration](/get-started/configuration#preconnect) to opt out.

### `loading`

This is a [native](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/loading) attribute that provides a hint
Expand Down
2 changes: 2 additions & 0 deletions docs/content/2.usage/2.nuxt-picture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Learn more about the [`<picture>` tag on MDN](https://developer.mozilla.org/en-U
Unlike the native [`<picture>` element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture), `<NuxtPicture>` currently doesn't support using multiple image sources. See [#309](https://github.com/nuxt/image/issues/309) for more info.
::

**Note:** Like `<NuxtImg>`, `<NuxtPicture>` preconnects to the origins of its external image sources during server rendering. See the [`preconnect` configuration](/get-started/configuration#preconnect) to opt out.

## Props

::tip
Expand Down
10 changes: 10 additions & 0 deletions playground/app/pages/picture-preconnect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template>
<div>
<nuxt-picture
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="500"
height="500"
/>
</div>
</template>
18 changes: 18 additions & 0 deletions playground/app/pages/preconnect-crossorigin-picture.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<template>
<div>
<nuxt-picture
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="100"
height="100"
:img-attrs="{ crossorigin: 'use-credentials' }"
/>
<nuxt-picture
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="120"
height="120"
:img-attrs="{ crossorigin: '' }"
/>
</div>
</template>
24 changes: 24 additions & 0 deletions playground/app/pages/preconnect-crossorigin.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<div>
<nuxt-img
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="100"
height="100"
crossorigin="anonymous"
/>
<nuxt-img
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="120"
height="120"
/>
<nuxt-img
provider="cloudinary"
src="/remote/nuxt-org/blog/going-full-static/main"
width="140"
height="140"
crossorigin="anonymous"
/>
</div>
</template>
10 changes: 10 additions & 0 deletions playground/app/pages/preconnect-placeholder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template>
<div>
<nuxt-img
src="/images/nuxt.png"
width="100"
height="100"
placeholder="https://res.cloudinary.com/nuxt/image/upload/images/nuxt.png"
/>
</div>
</template>
3 changes: 3 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface ModuleOptions extends ImageProviders {
densities: number[]
format: CreateImageOptions['format']
quality?: CreateImageOptions['quality']
preconnect: boolean
}

export * from './types'
Expand All @@ -45,6 +46,7 @@ export default defineNuxtModule<ModuleOptions>({
providers: {},
alias: {},
densities: [1, 2],
preconnect: true,
}),
meta: {
name: '@nuxt/image',
Expand Down Expand Up @@ -113,6 +115,7 @@ export default defineNuxtModule<ModuleOptions>({
'densities',
'format',
'quality',
'preconnect',
])

const providers = await resolveProviders(nuxt, options)
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/components/NuxtImg.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { computed, onMounted, ref, useAttrs, useTemplateRef } from 'vue'
import type { ImgHTMLAttributes } from 'vue'

import { useImage } from '../composables'
import { useImage, usePreconnect } from '../composables'
import { prerenderStaticImages } from '../utils/prerender'
import { markFeatureUsage } from '../utils/performance'
import { useImageProps } from '../utils/props'
Expand Down Expand Up @@ -101,6 +101,11 @@ const mainSrc = computed(() =>
: $img(props.src!, imageModifiers.value, providerOptions.value),
)

if (import.meta.server && $img.options.preconnect !== false) {
usePreconnect(mainSrc.value, { crossorigin: normalizedAttrs.value.crossorigin })
usePreconnect(placeholder.value || undefined, { crossorigin: normalizedAttrs.value.crossorigin })
}

const src = computed(() => placeholder.value || mainSrc.value)

if (import.meta.server && props.preload) {
Expand Down
10 changes: 9 additions & 1 deletion src/runtime/components/NuxtPicture.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { computed, onMounted, useAttrs, useTemplateRef } from 'vue'

import { prerenderStaticImages } from '../utils/prerender'
import { markFeatureUsage } from '../utils/performance'
import { useImage } from '../composables'
import { useImage, usePreconnect, type PreconnectOptions } from '../composables'
import { useImageProps } from '../utils/props'
import type { BaseImageProps } from '../utils/props'
import type { DataAttributes } from '../types'
Expand Down Expand Up @@ -119,6 +119,14 @@ const sources = computed<Source[]>(() => {
})
})

if (import.meta.server && $img.options.preconnect !== false) {
// `imgAttrs` is merged into `attrs.img` after the prop, so use that same value
const crossorigin = attrs.value.img.crossorigin as PreconnectOptions['crossorigin']
for (const source of sources.value) {
usePreconnect(source.src, { crossorigin })
}
}

if (import.meta.server && props.preload) {
useHead({ link: () => {
const firstSource = sources.value[0]
Expand Down
45 changes: 44 additions & 1 deletion src/runtime/composables.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { H3Event } from 'h3'
import { createImage } from './image'
import { resolvePreconnectOrigin } from './utils/preconnect'
import type { $Img } from '@nuxt/image'

import { imageOptions } from '#build/image-options.mjs'
import { createError, useNuxtApp, useRuntimeConfig } from '#imports'
import { createError, useHead, useNuxtApp, useRequestURL, useRuntimeConfig } from '#imports'

export const useImage = (event?: H3Event): $Img => {
const config = useRuntimeConfig()
Expand All @@ -19,3 +20,45 @@ export const useImage = (event?: H3Event): $Img => {
runtimeConfig: config,
}))
}

export interface PreconnectOptions {
crossorigin?: 'anonymous' | 'use-credentials' | '' | boolean
}

/**
* Registers a keyed `<link rel="preconnect">` for the origin of a rendered
* image URL, so unhead dedupes repeated origins into a single hint. No-op on
* the client, and for empty, relative, same-origin or non-http(s) URLs.
*/
export const usePreconnect = (url: string | undefined, options: PreconnectOptions = {}): void => {
if (import.meta.client) {
return
}

let base: string
try {
base = useRequestURL().href
}
catch {
return
}

const origin = resolvePreconnectOrigin(url, base)
if (!origin) {
return
}

// an empty crossorigin attribute means "anonymous", same as `true`
const crossorigin = options.crossorigin === true || options.crossorigin === ''
? 'anonymous'
: (options.crossorigin || undefined)

useHead({
link: [{
rel: 'preconnect',
href: origin,
key: `preconnect:${origin}${crossorigin ? `:${crossorigin}` : ''}`,
...(crossorigin ? { crossorigin } : {}),
}],
})
}
32 changes: 32 additions & 0 deletions src/runtime/utils/preconnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Resolve the origin a cross-origin image URL should be preconnected to.
*
* Returns `undefined` when the URL is empty, relative, same-origin as
* `baseHref`, not parseable, or not an `http(s)` URL (so `data:`, `blob:`
* and other schemes are ignored).
*/
export function resolvePreconnectOrigin(url: string | undefined, baseHref: string): string | undefined {
if (!url) {
return
}

let base: URL
let resolved: URL
try {
base = new URL(baseHref)
resolved = new URL(url, base)
}
catch {
return
}

if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
return
}

if (resolved.origin === base.origin) {
return
}

return resolved.origin
}
1 change: 1 addition & 0 deletions src/types/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface CreateImageOptions {
densities: number[]
format: string[]
quality?: number
preconnect?: boolean
runtimeConfig: RuntimeConfig
}

Expand Down
86 changes: 86 additions & 0 deletions test/e2e/ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,90 @@ describe('browser (ssr: true)', () => {
}
`)
})

it('preconnects to a remote provider origin', async () => {
const page = await createPage()
await page.goto(url('/provider/cloudinary'))
await page.waitForSelector('img')

const origins = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => link.getAttribute('href')))

expect(origins).toContain('https://res.cloudinary.com')
expect(origins.filter(origin => origin === 'https://res.cloudinary.com')).toHaveLength(1)

await page.close()
})

it('does not preconnect for same-origin images', async () => {
const page = await createPage()
await page.goto(url('/img-preload'))
await page.waitForSelector('img')

const origins = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => link.getAttribute('href')))

expect(origins).toEqual([])

await page.close()
})

it('preconnects for a remote provider rendered by NuxtPicture', async () => {
const page = await createPage()
await page.goto(url('/picture-preconnect'))
await page.waitForSelector('img')

const origins = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => link.getAttribute('href')))

expect(origins).toContain('https://res.cloudinary.com')

await page.close()
})

it('mirrors crossorigin on the preconnect hint and keeps connection modes distinct', async () => {
const page = await createPage()
await page.goto(url('/preconnect-crossorigin'))
await page.waitForSelector('img')

const hints = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => ({
href: link.getAttribute('href'),
crossorigin: link.getAttribute('crossorigin'),
})))

// Three images share one origin, but only two connection modes were requested
expect(hints).toHaveLength(2)
expect(hints).toContainEqual({ href: 'https://res.cloudinary.com', crossorigin: 'anonymous' })
expect(hints).toContainEqual({ href: 'https://res.cloudinary.com', crossorigin: null })

await page.close()
})

it('preconnects to a remote placeholder origin', async () => {
const page = await createPage()
// the placeholder is the rendered `src`, so only its origin should be hinted
await page.goto(url('/preconnect-placeholder'), { waitUntil: 'domcontentloaded' })

const origins = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => link.getAttribute('href')))

expect(origins).toEqual(['https://res.cloudinary.com'])

await page.close()
})

it('matches the picture preconnect hint to the crossorigin the image renders', async () => {
const page = await createPage()
await page.goto(url('/preconnect-crossorigin-picture'), { waitUntil: 'domcontentloaded' })

const rendered = await page.$$eval('picture img', imgs => imgs.map(img => img.getAttribute('crossorigin')))
const hints = await page.$$eval('head link[rel="preconnect"]', links => links.map(link => ({
href: link.getAttribute('href'),
crossorigin: link.getAttribute('crossorigin'),
})))

// `imgAttrs` is applied after the prop, so it wins: 'use-credentials' and '' (anonymous)
expect(rendered).toEqual(['use-credentials', ''])
expect(hints).toHaveLength(2)
expect(hints).toContainEqual({ href: 'https://res.cloudinary.com', crossorigin: 'use-credentials' })
expect(hints).toContainEqual({ href: 'https://res.cloudinary.com', crossorigin: 'anonymous' })

await page.close()
})
})
2 changes: 1 addition & 1 deletion test/unit/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe.skipIf(process.env.ECOSYSTEM_CI || isWindows)('nuxt image bundle size',
image: { provider: 'ipx' },
})

expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.4k"`)
expect(roundToKilobytes(withImage.totalBytes - withoutImage.totalBytes)).toMatchInlineSnapshot(`"12.5k"`)
})
})

Expand Down
Loading
Loading