Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for static image imports #24993

Merged
merged 37 commits into from
Jun 4, 2021
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
996dc7f
Add static image loading functionality with auto height and width
atcastle May 10, 2021
6dea6a2
Add tests for static image fill layout
atcastle May 10, 2021
21400ba
Update testing for height and width
atcastle May 11, 2021
c6ad7c5
Send cache-control immutable header for static assets
atcastle May 11, 2021
b5f2c12
Add tests for static image cache control behavior
atcastle May 11, 2021
bc5c64a
Add test for build breaking on bad image path
atcastle May 11, 2021
aa9e4fd
Move static image tests into default test suite and add format test
atcastle May 11, 2021
9b4c513
Put static image behind experimental flag
atcastle May 11, 2021
fdd27c4
Fix support for images loaded with Require
atcastle May 11, 2021
7c138de
Remove extraneous code from next-image-loader
atcastle May 11, 2021
ccefcb2
Initial addition of new loader for converting new URL() usage
atcastle May 13, 2021
acd4ae3
Change static query parameter value from true to 1
atcastle May 14, 2021
5a8ce4c
Update tests for static query param
atcastle May 14, 2021
29892e3
Support new URL constructor for image component
atcastle May 18, 2021
108aed9
Revert "Support new URL constructor for image component"
atcastle May 24, 2021
b6a199a
Revert "Initial addition of new loader for converting new URL() usage"
atcastle May 24, 2021
d4149d2
Merge branch 'canary' of github.com:vercel/next.js into static-image
atcastle May 24, 2021
809d86e
Integrate static image and blurry placeholder
atcastle May 24, 2021
2e183bd
Return from image loader after emitting error
atcastle May 24, 2021
85d3d06
Support additional file types for image loader
atcastle May 24, 2021
f937233
standardize variable names for isStatic image
atcastle May 24, 2021
9659f46
rename staticImages experimental config to enableStaticImages
atcastle May 24, 2021
c65e416
Only set height and width for static images if they're not provided
atcastle May 26, 2021
357523a
Refactor static image argument detection to use type guards
atcastle May 26, 2021
0ed6844
Fix static image test label
atcastle May 26, 2021
6b8f5fb
Remove unneccessary lines from next-image-loader
atcastle May 26, 2021
a23b5f0
In image-loader, shrink images based on largest dimension
atcastle May 28, 2021
6cb22b8
Rename dataURI to placeholder in image loader
atcastle May 28, 2021
88d28f2
Exclude URL dependencies from using image loader
atcastle May 28, 2021
cb63fbb
Allow image's to be imported from locations other than /public
atcastle May 28, 2021
8d4938e
Removed unneeded AssetModuleFilename property
atcastle May 28, 2021
09b0799
Reuse buffer from webpack
timneutkens May 31, 2021
4e387d7
Update packages/next/next-server/server/image-optimizer.ts
timneutkens May 31, 2021
9932cd8
Update image-optimizer tests to reflect static image changes
atcastle Jun 3, 2021
6a94a6d
Merge remote-tracking branch 'origin' into static-image
atcastle Jun 3, 2021
cfe7b38
Run prettier on image-optimizer.ts
atcastle Jun 3, 2021
0956c0d
Merge branch 'canary' into static-image
styfle Jun 3, 2021
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
10 changes: 10 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,7 @@ export default async function getBaseWebpackConfig(
'error-loader',
'next-babel-loader',
'next-client-pages-loader',
'next-image-loader',
'next-serverless-loader',
'noop-loader',
'next-style-loader',
Expand Down Expand Up @@ -997,6 +998,15 @@ export default async function getBaseWebpackConfig(
]
: defaultLoaders.babel,
},
...(config.experimental.enableStaticImages
? [
{
test: /\.(png|svg|jpg|jpeg|gif|webp|ico|bmp)$/i,
loader: 'next-image-loader',
atcastle marked this conversation as resolved.
Show resolved Hide resolved
dependency: { not: ['url'] },
},
]
: []),
].filter(Boolean),
},
plugins: [
Expand Down
52 changes: 52 additions & 0 deletions packages/next/build/webpack/loaders/next-image-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import loaderUtils from 'next/dist/compiled/loader-utils'
import sizeOf from 'image-size'
import { processBuffer } from '../../../next-server/server/lib/squoosh/main'

const PLACEHOLDER_SIZE = 6

async function nextImageLoader(content) {
const context = this.rootContext
const opts = { context, content }
const interpolatedName = loaderUtils.interpolateName(
this,
'/static/image/[path][name].[hash].[ext]',
opts
)

let extension = loaderUtils.interpolateName(this, '[ext]', opts)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use https://www.npmjs.com/package/file-type instead, this ensures the content is always the correct type.

if (extension === 'jpg') {
extension = 'jpeg'
}

const imageSize = sizeOf(content)
let placeholder
if (extension === 'jpeg' || extension === 'png') {
timneutkens marked this conversation as resolved.
Show resolved Hide resolved
// Shrink the image's largest dimension to 6 pixels
const resizeOperationOpts =
imageSize.width >= imageSize.height
? { type: 'resize', width: PLACEHOLDER_SIZE }
: { type: 'resize', height: PLACEHOLDER_SIZE }
const resizedImage = await processBuffer(
content,
[resizeOperationOpts],
extension,
0
)
placeholder = `data:image/${extension};base64,${resizedImage.toString(
'base64'
)}`
}

const stringifiedData = JSON.stringify({
src: '/_next' + interpolatedName,
height: imageSize.height,
width: imageSize.width,
placeholder,
})

this.emitFile(interpolatedName, content, null)

return `${'export default '} ${stringifiedData};`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to put the meta information into a separate export, so the default export stays only the URL for backward-compat reasons. Currently enabling this feature, breaks all existing image imports as it returns an object instead of an url now.

You would use it like that:

import * as testImg from '../public/foo/test-rect.jpg'

      <Image
        id="basic-static"
        src={testImg}
        layout="fixed"
        placeholder="blur"
      />

@timneutkens What do you think?

The import might be easy to get wrong... A lint rule could help with that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently enabling this feature, breaks all existing image imports as it returns an object instead of an url now.

Image imports are not supported in Next.js currently, so that should be fine, potentially we can do the same as the css imports detection to disable the loader if there's customization that would break? 🤔

}
export const raw = true
export default nextImageLoader
78 changes: 73 additions & 5 deletions packages/next/client/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ImageLoaderProps = {
src: string
width: number
quality?: number
isStatic?: boolean
}

type DefaultImageLoaderProps = ImageLoaderProps & { root: string }
Expand Down Expand Up @@ -49,11 +50,44 @@ type PlaceholderValue = 'blur' | 'empty'

type ImgElementStyle = NonNullable<JSX.IntrinsicElements['img']['style']>

interface StaticImageData {
src: string
height: number
width: number
placeholder?: string
}

interface StaticRequire {
default: StaticImageData
}

type StaticImport = StaticRequire | StaticImageData

function isStaticRequire(
src: StaticRequire | StaticImageData
): src is StaticRequire {
return (src as StaticRequire).default !== undefined
}

function isStaticImageData(
src: StaticRequire | StaticImageData
): src is StaticImageData {
return (src as StaticImageData).src !== undefined
}

function isStaticImport(src: string | StaticImport): src is StaticImport {
return (
typeof src === 'object' &&
(isStaticRequire(src as StaticImport) ||
isStaticImageData(src as StaticImport))
)
}

export type ImageProps = Omit<
JSX.IntrinsicElements['img'],
'src' | 'srcSet' | 'ref' | 'width' | 'height' | 'loading' | 'style'
> & {
src: string
src: string | StaticImport
loader?: ImageLoader
quality?: number | string
priority?: boolean
Expand Down Expand Up @@ -151,6 +185,7 @@ type GenImgAttrsData = {
unoptimized: boolean
layout: LayoutValue
loader: ImageLoader
isStatic?: boolean
atcastle marked this conversation as resolved.
Show resolved Hide resolved
width?: number
quality?: number
sizes?: string
Expand All @@ -170,6 +205,7 @@ function generateImgAttrs({
quality,
sizes,
loader,
isStatic,
}: GenImgAttrsData): GenImgAttrsResult {
if (unoptimized) {
return { src, srcSet: undefined, sizes: undefined }
Expand All @@ -183,7 +219,7 @@ function generateImgAttrs({
srcSet: widths
.map(
(w, i) =>
`${loader({ src, quality, width: w })} ${
`${loader({ src, quality, isStatic, width: w })} ${
kind === 'w' ? w : i + 1
}${kind}`
)
Expand All @@ -195,7 +231,7 @@ function generateImgAttrs({
// updated by React. That causes multiple unnecessary requests if `srcSet`
// and `sizes` are defined.
// This bug cannot be reproduced in Chrome or Firefox.
src: loader({ src, quality, width: widths[last] }),
src: loader({ src, quality, isStatic, width: widths[last] }),
}
}

Expand Down Expand Up @@ -276,6 +312,35 @@ export default function Image({
if (!configEnableBlurryPlaceholder) {
placeholder = 'empty'
}
const isStatic = typeof src === 'object'
let staticSrc = ''
if (isStaticImport(src)) {
const staticImageData = isStaticRequire(src) ? src.default : src

if (!staticImageData.src) {
throw new Error(
`An object should only be passed to the image component src parameter if it comes from a static image import. It must include src. Received ${JSON.stringify(
staticImageData
)}`
)
}
if (staticImageData.placeholder) {
blurDataURL = staticImageData.placeholder
}
staticSrc = staticImageData.src
if (!layout || layout !== 'fill') {
height = height || staticImageData.height
width = width || staticImageData.width
if (!staticImageData.height || !staticImageData.width) {
throw new Error(
`An object should only be passed to the image component src parameter if it comes from a static image import. It must include height and width. Received ${JSON.stringify(
staticImageData
)}`
)
}
}
}
src = (isStatic ? staticSrc : src) as string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
src = (isStatic ? staticSrc : src) as string
src = staticSrc || src

Will this work?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this casting, a lot of references to src further down the file have problems because they only want a string. Since as of this line, we know src is a string no matter what, I think the cast is probably ok.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
src = (isStatic ? staticSrc : src) as string
src = typeof src === ‘string’ ? src : staticSrc


if (process.env.NODE_ENV !== 'production') {
if (!src) {
Expand Down Expand Up @@ -310,7 +375,6 @@ export default function Image({
)
}
}

let isLazy =
!priority && (loading === 'lazy' || typeof loading === 'undefined')
if (src && src.startsWith('data:')) {
Expand Down Expand Up @@ -458,6 +522,7 @@ export default function Image({
quality: qualityInt,
sizes,
loader,
isStatic,
})
}

Expand Down Expand Up @@ -590,6 +655,7 @@ function cloudinaryLoader({

function defaultLoader({
root,
isStatic,
src,
width,
quality,
Expand Down Expand Up @@ -637,5 +703,7 @@ function defaultLoader({
}
}

return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`
return `${root}?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}${
isStatic ? '&s=1' : ''
}`
}
2 changes: 2 additions & 0 deletions packages/next/next-server/server/config-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type NextConfig = { [key: string]: any } & {
workerThreads?: boolean
pageEnv?: boolean
optimizeImages?: boolean
enableStaticImages?: boolean
optimizeCss?: boolean
scrollRestoration?: boolean
scriptLoader?: boolean
Expand Down Expand Up @@ -110,6 +111,7 @@ export const defaultConfig: NextConfig = {
workerThreads: false,
pageEnv: false,
optimizeImages: false,
enableStaticImages: false,
optimizeCss: false,
scrollRestoration: false,
scriptLoader: false,
Expand Down
24 changes: 18 additions & 6 deletions packages/next/next-server/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function imageOptimizer(
}

const { headers } = req
const { url, w, q } = parsedUrl.query
const { url, w, q, s } = parsedUrl.query
const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept)
let href: string

Expand Down Expand Up @@ -111,6 +111,14 @@ export async function imageOptimizer(
return { finished: true }
}

if (s && s !== '1') {
res.statusCode = 400
res.end('"s" parameter must be "1" or omitted')
return { finished: true }
}

const isStatic = !!s

const width = parseInt(w, 10)

if (!width || isNaN(width)) {
Expand Down Expand Up @@ -261,7 +269,7 @@ export async function imageOptimizer(
ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)
if (vector || animate) {
await writeToCacheDir(hashDir, upstreamType, expireAt, upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer, isStatic)
return { finished: true }
}

Expand Down Expand Up @@ -333,12 +341,12 @@ export async function imageOptimizer(

if (optimizedBuffer) {
await writeToCacheDir(hashDir, contentType, expireAt, optimizedBuffer)
sendResponse(req, res, contentType, optimizedBuffer)
sendResponse(req, res, contentType, optimizedBuffer, isStatic)
} else {
throw new Error('Unable to optimize buffer')
}
} catch (error) {
sendResponse(req, res, upstreamType, upstreamBuffer)
sendResponse(req, res, upstreamType, upstreamBuffer, isStatic)
}

return { finished: true }
Expand Down Expand Up @@ -366,10 +374,14 @@ function sendResponse(
req: IncomingMessage,
res: ServerResponse,
contentType: string | null,
buffer: Buffer
buffer: Buffer,
isStatic: boolean
) {
const etag = getHash([buffer])
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
res.setHeader(
'Cache-Control',
isStatic ? 'public, immutable, max-age=315360000' : 'public, max-age=0, must-revalidate'
)
if (sendEtagResponse(req, res, etag)) {
return
}
Expand Down
8 changes: 7 additions & 1 deletion packages/next/next-server/server/lib/squoosh/impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,20 @@ export async function rotate(
return await m(image.data, image.width, image.height, { numRotations })
}

export async function resize(image: ImageData, width: number) {
type ResizeOpts = { image: ImageData } & (
| { width: number; height?: never }
| { height: number; width?: never }
)

export async function resize({ image, width, height }: ResizeOpts) {
image = ImageData.from(image)

const p = preprocessors['resize']
const m = await p.instantiate()
return await m(image.data, image.width, image.height, {
...p.defaultOptions,
width,
height,
})
}

Expand Down
23 changes: 19 additions & 4 deletions packages/next/next-server/server/lib/squoosh/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ type RotateOperation = {
}
type ResizeOperation = {
type: 'resize'
width: number
}
} & ({ width: number; height?: never } | { height: number; width?: never })
export type Operation = RotateOperation | ResizeOperation
export type Encoding = 'jpeg' | 'png' | 'webp'

Expand Down Expand Up @@ -38,8 +37,24 @@ export async function processBuffer(
if (operation.type === 'rotate') {
imageData = await worker.rotate(imageData, operation.numRotations)
} else if (operation.type === 'resize') {
if (imageData.width && imageData.width > operation.width) {
imageData = await worker.resize(imageData, operation.width)
if (
operation.width &&
imageData.width &&
imageData.width > operation.width
) {
imageData = await worker.resize({
image: imageData,
width: operation.width,
})
} else if (
operation.height &&
imageData.height &&
imageData.height > operation.height
) {
imageData = await worker.resize({
image: imageData,
height: operation.height,
})
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"raw-body": "2.4.1",
"react-is": "16.13.1",
"react-refresh": "0.8.3",
"image-size": "1.0.0",
"stream-browserify": "3.0.0",
"stream-http": "3.1.1",
"string_decoder": "1.3.0",
Expand Down
Loading