diff --git a/code/e2e-tests/framework-nextjs.spec.ts b/code/e2e-tests/framework-nextjs.spec.ts index c2c40733c45..c69825c42b5 100644 --- a/code/e2e-tests/framework-nextjs.spec.ts +++ b/code/e2e-tests/framework-nextjs.spec.ts @@ -21,6 +21,30 @@ test.describe('Next.js', () => { await new SbPage(page).waitUntilLoaded(); }); + test.describe('next/image', () => { + let sbPage: SbPage; + + test.beforeEach(async ({ page }) => { + sbPage = new SbPage(page); + }); + + test('should lazy load images by default', async () => { + await sbPage.navigateToStory('frameworks/nextjs/Image', 'lazy'); + + const img = sbPage.previewRoot().locator('img'); + + expect(await img.evaluate((image) => image.complete)).toBeFalsy(); + }); + + test('should eager load images when loading parameter is set to eager', async () => { + await sbPage.navigateToStory('frameworks/nextjs/Image', 'eager'); + + const img = sbPage.previewRoot().locator('img'); + + expect(await img.evaluate((image) => image.complete)).toBeTruthy(); + }); + }); + test.describe('next/navigation', () => { let root: Locator; let sbPage: SbPage; diff --git a/code/frameworks/nextjs/README.md b/code/frameworks/nextjs/README.md index ee5a3fc5546..ae7534835dc 100644 --- a/code/frameworks/nextjs/README.md +++ b/code/frameworks/nextjs/README.md @@ -159,12 +159,16 @@ export default { framework: { name: '@storybook/nextjs', options: { + image: { + loading: 'eager', + }, nextConfigPath: path.resolve(__dirname, '../next.config.js'), }, }, }; ``` +- `image`: Props to pass to every instance of `next/image` - `nextConfigPath`: The absolute path to the `next.config.js` ### Next.js's Image Component diff --git a/code/frameworks/nextjs/src/images/context.ts b/code/frameworks/nextjs/src/images/context.ts new file mode 100644 index 00000000000..81f8cf2d6c8 --- /dev/null +++ b/code/frameworks/nextjs/src/images/context.ts @@ -0,0 +1,16 @@ +import { createContext } from 'react'; +import type { ImageProps, StaticImageData } from 'next/image'; +import type { ImageProps as LegacyImageProps } from 'next/legacy/image'; + +// StaticRequire needs to be in scope for the TypeScript compiler to work. +// See: https://github.com/microsoft/TypeScript/issues/5711 +// Since next/image doesn't export StaticRequire we need to re-define it here and set src's type to it. +interface StaticRequire { + default: StaticImageData; +} + +declare type StaticImport = StaticRequire | StaticImageData; + +export const ImageContext = createContext< + Partial & { src: string | StaticImport }> & Omit +>({}); diff --git a/code/frameworks/nextjs/src/images/decorator.tsx b/code/frameworks/nextjs/src/images/decorator.tsx new file mode 100644 index 00000000000..f0917b3a3b5 --- /dev/null +++ b/code/frameworks/nextjs/src/images/decorator.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import type { Addon_StoryContext } from '@storybook/types'; +import { ImageContext } from './context'; + +export const ImageDecorator = ( + Story: React.FC, + { parameters }: Addon_StoryContext +): React.ReactNode => { + if (!parameters.nextjs?.image) { + return ; + } + + return ( + + + + ); +}; diff --git a/code/frameworks/nextjs/src/images/next-image-stub.tsx b/code/frameworks/nextjs/src/images/next-image-stub.tsx index 7fc7268b5c7..3fc92c7b29a 100644 --- a/code/frameworks/nextjs/src/images/next-image-stub.tsx +++ b/code/frameworks/nextjs/src/images/next-image-stub.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import type * as _NextImage from 'next/image'; import type * as _NextLegacyImage from 'next/legacy/image'; import semver from 'semver'; +import { ImageContext } from './context'; const defaultLoader = ({ src, width, quality }: _NextImage.ImageLoaderProps) => { const missingValues = []; @@ -38,7 +39,11 @@ const OriginalNextImage = NextImage.default; Object.defineProperty(NextImage, 'default', { configurable: true, value: (props: _NextImage.ImageProps) => { - return ; + const imageParameters = React.useContext(ImageContext); + + return ( + + ); }, }); @@ -48,9 +53,17 @@ if (semver.satisfies(process.env.__NEXT_VERSION!, '^13.0.0')) { Object.defineProperty(OriginalNextLegacyImage, 'default', { configurable: true, - value: (props: _NextLegacyImage.ImageProps) => ( - - ), + value: (props: _NextLegacyImage.ImageProps) => { + const imageParameters = React.useContext(ImageContext); + + return ( + + ); + }, }); } @@ -60,8 +73,16 @@ if (semver.satisfies(process.env.__NEXT_VERSION!, '^12.2.0')) { Object.defineProperty(OriginalNextFutureImage, 'default', { configurable: true, - value: (props: _NextImage.ImageProps) => ( - - ), + value: (props: _NextImage.ImageProps) => { + const imageParameters = React.useContext(ImageContext); + + return ( + + ); + }, }); } diff --git a/code/frameworks/nextjs/src/preview.tsx b/code/frameworks/nextjs/src/preview.tsx index 85a9f33770c..d089aa27f0d 100644 --- a/code/frameworks/nextjs/src/preview.tsx +++ b/code/frameworks/nextjs/src/preview.tsx @@ -1,5 +1,6 @@ import type { Addon_DecoratorFunction } from '@storybook/types'; import './config/preview'; +import { ImageDecorator } from './images/decorator'; import { RouterDecorator } from './routing/decorator'; import { StyledJsxDecorator } from './styledJsx/decorator'; import './images/next-image-stub'; @@ -16,6 +17,7 @@ addNextHeadCount(); export const decorators: Addon_DecoratorFunction[] = [ StyledJsxDecorator, + ImageDecorator, RouterDecorator, HeadManagerDecorator, ]; diff --git a/code/frameworks/nextjs/template/stories/Image.stories.jsx b/code/frameworks/nextjs/template/stories/Image.stories.jsx index c30778804df..bf3808282a6 100644 --- a/code/frameworks/nextjs/template/stories/Image.stories.jsx +++ b/code/frameworks/nextjs/template/stories/Image.stories.jsx @@ -48,3 +48,30 @@ export const Sized = { ], }, }; + +export const Lazy = { + args: { + src: 'https://storybook.js.org/images/placeholders/50x50.png', + width: 50, + height: 50, + }, + decorators: [ + (Story) => ( + <> +
+ {Story()} + + ), + ], +}; + +export const Eager = { + ...Lazy, + parameters: { + nextjs: { + image: { + loading: 'eager', + }, + }, + }, +};