Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
128 changes: 128 additions & 0 deletions packages/img/src/next/imageAttributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { DOMAttributes, ImgHTMLAttributes } from 'react'

/** Native attributes that can cross the server-rendering boundary, without caller-owned URLs. */
export interface ImageAttributes
extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
| keyof DOMAttributes<HTMLImageElement>
| 'defaultChecked'
| 'defaultValue'
| 'inlist'
| 'loading'
| 'src'
| 'srcSet'
| 'suppressContentEditableWarning'
| 'suppressHydrationWarning'
| 'tw'
> {
// React types this RDFa attribute as any; only serializable values belong in this API.
inlist?: string
[attribute: `data-${string}`]: string | number | boolean | null | undefined
}

// Exhaustive against React's native img attributes. New React attributes require an explicit
// decision here; arbitrary JS props must never leak factory configuration or override URLs.
const nativeAttributes: Record<
Exclude<keyof ImageAttributes, 'style' | `aria-${string}` | `data-${string}`>,
true
> = {
about: true,
accessKey: true,
alt: true,
autoCapitalize: true,
autoCorrect: true,
autoFocus: true,
autoSave: true,
className: true,
color: true,
content: true,
contentEditable: true,
contextMenu: true,
crossOrigin: true,
datatype: true,
decoding: true,
dir: true,
draggable: true,
enterKeyHint: true,
exportparts: true,
fetchPriority: true,
height: true,
hidden: true,
id: true,
inert: true,
inlist: true,
inputMode: true,
is: true,
itemID: true,
itemProp: true,
itemRef: true,
itemScope: true,
itemType: true,
lang: true,
nonce: true,
part: true,
popover: true,
popoverTarget: true,
popoverTargetAction: true,
prefix: true,
property: true,
radioGroup: true,
referrerPolicy: true,
rel: true,
resource: true,
results: true,
rev: true,
role: true,
security: true,
sizes: true,
slot: true,
spellCheck: true,
tabIndex: true,
title: true,
translate: true,
typeof: true,
unselectable: true,
useMap: true,
vocab: true,
width: true,
}

/** Snapshots only native, serializable attributes before suspension or rendering. */
export function snapshotImageAttributes(props: ImageAttributes): ImageAttributes {
const style = props.style
if (style != null && (typeof style !== 'object' || Array.isArray(style))) {
throw new TypeError('Image style must be an object')
}
const attributes = Object.fromEntries(
Object.entries(props).filter(
([name, value]) =>
(Object.hasOwn(nativeAttributes, name) || /^(?:aria|data)-[\w.-]+$/.test(name)) &&
(value === undefined ||
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'),
),
)
return { ...attributes, style: style == null ? undefined : { ...style } }
}

/** A preload is eager; explicitly lazy images must not issue preload requests. */
export type ImageLoadingProps =
| { loading?: 'eager'; preload: true }
| { loading?: 'eager' | 'lazy'; preload?: false }

/** Retains runtime validation for JavaScript callers as well as the discriminated public type. */
export function snapshotImageLoading({
loading,
preload,
}: {
loading?: 'eager' | 'lazy'
preload?: boolean
}): ImageLoadingProps {
if (preload) {
if (loading === 'lazy') throw new Error('A preloaded Transloadit image cannot use lazy loading')
return { loading, preload }
}
return { loading, preload }
}
107 changes: 37 additions & 70 deletions packages/img/src/next/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import type {
TransloaditImageModel,
TransloaditImageSourceSet,
} from '../index.ts'
import type { ImageAttributes, ImageLoadingProps } from './imageAttributes.ts'

import { preload as preloadResource } from 'react-dom'

import { HydratedTransloaditPicture } from './HydratedTransloaditPicture.tsx'
import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts'

const transparentPixel =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
Expand All @@ -18,28 +20,25 @@ const mimeTypes = {
webp: 'image/webp',
} satisfies Record<TransloaditImageSourceSet['format'], string>

/** Presentation options shared by the signed Server Component and model-only renderer. */
export interface TransloaditImagePresentationProps {
interface ImagePresentationProps extends ImageAttributes {
alt: string
className?: string
deferUntilHydrated?: boolean
fetchPriority?: 'auto' | 'high' | 'low'
height: number
loading?: 'eager' | 'lazy'
media?: string
/** CSP-compatible placeholder used while `media` is unmatched. Defaults to an inline GIF. */
mediaPlaceholderSrc?: string
/** Explicitly handles a display box whose aspect ratio differs from the source image. */
objectFit?: CSSProperties['objectFit']
preload?: boolean
/** Expected rendered widths. Browsers otherwise assume `100vw` for width-based source sets. */
sizes?: string
style?: CSSProperties
width: number
}

/** Serializable native image attributes and layout shared by both Next.js renderers. */
export type TransloaditImagePresentationProps = ImagePresentationProps & ImageLoadingProps

/** Props for rendering an already-signed framework-neutral image model. */
export interface TransloaditPictureProps extends TransloaditImagePresentationProps {
export type TransloaditPictureProps = TransloaditImagePresentationProps & {
model: TransloaditImageModel
}

Expand Down Expand Up @@ -75,8 +74,8 @@ function escapeSourceSetUrl(url: string): string {

function preloadImage(
source: TransloaditImageSourceSet,
sizes: string | undefined,
fetchPriority?: 'auto' | 'high' | 'low',
sizes: string,
{ crossOrigin, fetchPriority, referrerPolicy }: ImageAttributes,
): void {
const firstCandidate = source.candidates[0]
if (firstCandidate === undefined) {
Expand All @@ -85,94 +84,62 @@ function preloadImage(

preloadResource(firstCandidate.url, {
as: 'image',
crossOrigin,
fetchPriority,
imageSizes: sizes,
imageSrcSet: getSourceSet(source.candidates),
referrerPolicy,
type: getMimeType(source.format),
})
}

function OriginalImage({
alt,
className,
fetchPriority,
height,
loading,
objectFit,
src,
style,
width,
}: Pick<
TransloaditImagePresentationProps,
'alt' | 'className' | 'fetchPriority' | 'height' | 'loading' | 'objectFit' | 'style' | 'width'
> & {
src?: string
}): ReactNode {
return (
// biome-ignore lint/performance/noImgElement: This package is the image optimizer.
<img
alt={alt}
className={className}
decoding="async"
fetchPriority={fetchPriority}
height={height}
loading={loading}
src={src}
style={objectFit === undefined ? style : { ...style, objectFit }}
width={width}
/>
)
}

/**
* Renders browser-selected responsive candidates with one fallback. `media` keeps an unmatched
* viewport inert; the caller controls whether its layout still reserves space in that viewport.
* `deferUntilHydrated` avoids WebKit parser-to-hydration request replay.
*/
export function TransloaditPicture({
alt,
className,
deferUntilHydrated = false,
fetchPriority,
height,
loading,
media,
mediaPlaceholderSrc,
model,
objectFit,
preload = false,
sizes,
style,
width,
}: TransloaditPictureProps): ReactNode {
export function TransloaditPicture(props: TransloaditPictureProps): ReactNode {
const {
deferUntilHydrated = false,
loading,
media,
mediaPlaceholderSrc,
model,
objectFit,
preload = false,
sizes = '100vw',
} = props
if (deferUntilHydrated && (loading === 'eager' || preload)) {
throw new Error('An eager or preloaded Transloadit image cannot be deferred until hydration')
}
if (preload && loading === 'lazy') {
throw new Error('A preloaded Transloadit image cannot use lazy loading')
}
snapshotImageLoading(props)
if (preload && media !== undefined) {
// React 19's responsive-preload identity omits media and can silently collapse art direction.
throw new Error('A media-gated Transloadit image cannot be preloaded')
}
const resolvedLoading = loading ?? (preload ? 'eager' : 'lazy')
const automaticSizes = /^auto(?:\s*,|\s*$)/i.test(sizes.trimStart())
if (automaticSizes && resolvedLoading !== 'lazy') {
throw new Error('Automatic image sizes require lazy loading')
}
if (model.sources.length === 0) {
throw new Error('Cannot render a Transloadit image without a source')
}

const attributes = snapshotImageAttributes(props)
const original = (
<OriginalImage
alt={alt}
className={className}
fetchPriority={fetchPriority}
height={height}
// biome-ignore lint/performance/noImgElement: This package is the image optimizer.
<img
{...attributes}
alt={props.alt}
decoding={attributes.decoding ?? 'async'}
loading={resolvedLoading}
objectFit={objectFit}
// Without img srcset, only lazy auto sizing is valid here. Fallback lengths stay on source.
sizes={automaticSizes ? 'auto' : undefined}
// The default avoids a request and broken-image UI. Strict img-src policies can supply a
// same-origin transparent asset; a matching <source> takes precedence over either fallback.
src={media ? (mediaPlaceholderSrc ?? transparentPixel) : model.fallbackUrl}
style={style}
width={width}
style={objectFit === undefined ? attributes.style : { ...attributes.style, objectFit }}
/>
)
const fallback = media ? (
Expand All @@ -189,7 +156,7 @@ export function TransloaditPicture({
if (preferredSource === undefined) {
throw new Error('Cannot preload a Transloadit image without a source')
}
preloadImage(preferredSource, sizes, fetchPriority)
preloadImage(preferredSource, sizes, attributes)
}

const picture = (
Expand Down
Loading