diff --git a/packages/img/src/next/imageAttributes.ts b/packages/img/src/next/imageAttributes.ts new file mode 100644 index 00000000..9d96e3d3 --- /dev/null +++ b/packages/img/src/next/imageAttributes.ts @@ -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, + | keyof DOMAttributes + | '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, + 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 } +} diff --git a/packages/img/src/next/index.tsx b/packages/img/src/next/index.tsx index bce31c63..299c06cd 100644 --- a/packages/img/src/next/index.tsx +++ b/packages/img/src/next/index.tsx @@ -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' @@ -18,28 +20,25 @@ const mimeTypes = { webp: 'image/webp', } satisfies Record -/** 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 } @@ -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) { @@ -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. - {alt} - ) -} - /** * 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 = ( - 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 ? ( @@ -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 = ( diff --git a/packages/img/src/next/server.tsx b/packages/img/src/next/server.tsx index 35b27329..7fdd5128 100644 --- a/packages/img/src/next/server.tsx +++ b/packages/img/src/next/server.tsx @@ -19,6 +19,7 @@ import { Suspense } from 'react' import { createTransloaditImageModel, transloaditStoragePreviewTemplate } from '../index.ts' import { validateStoragePath, validateStoragePathPrefix } from '../storagePath.ts' +import { snapshotImageAttributes, snapshotImageLoading } from './imageAttributes.ts' import { TransloaditPicture } from './index.tsx' const defaultStorageExpiresInMs = 60 * 60 * 1000 @@ -86,14 +87,8 @@ export interface TransloaditRedirectImageConfiguration extends TransloaditImageC } } -interface CommonTransloaditImageProps extends TransloaditImagePresentationProps { - /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */ - widths?: readonly number[] -} - /** Props for a private Transloadit Storage preview. */ -export interface TransloaditImageProps - extends Omit { +export type TransloaditImageProps = TransloaditImagePresentationProps & { /** Encoding quality for the signed JPEG fallback. Defaults to 75. */ fallbackQuality?: number formats?: StoragePreviewFormats @@ -103,8 +98,13 @@ export interface TransloaditImageProps src: string /** Static shell used only while direct request-time signing is suspended. */ suspenseFallback?: ReactNode + /** Advanced candidate override. Defaults to a conservative ladder capped at `width`. */ + widths?: readonly number[] } +/** Redirect images render synchronously and have no signing suspension to replace. */ +export type TransloaditRedirectImageProps = TransloaditImageProps & { suspenseFallback?: never } + /** One configured Next.js Server Component for Transloadit Storage objects. */ export type TransloaditImageComponent = (props: TransloaditImageProps) => ReactNode @@ -117,7 +117,8 @@ export interface TransloaditImageIntegration { } /** Redirect-delivery integration with a route handler for private Storage images. */ -export interface TransloaditRedirectImageIntegration extends TransloaditImageIntegration { +export interface TransloaditRedirectImageIntegration { + Image: (props: TransloaditRedirectImageProps) => ReactNode storageRoute: TransloaditStorageRoute } @@ -319,19 +320,15 @@ function snapshotStorageImageProps( path: string, ): TransloaditImageProps { return { + ...snapshotImageAttributes(props), + ...snapshotImageLoading(props), alt: props.alt, - className: props.className, deferUntilHydrated: props.deferUntilHydrated, fallbackQuality: props.fallbackQuality, - fetchPriority: props.fetchPriority, formats: props.formats === undefined ? undefined : { ...props.formats }, height: props.height, - loading: props.loading, objectFit: props.objectFit, - preload: props.preload, - sizes: props.sizes, src: path, - style: props.style === undefined ? undefined : { ...props.style }, suspenseFallback: props.suspenseFallback, width: props.width, widths: Array.isArray(props.widths) ? [...props.widths] : props.widths, @@ -346,27 +343,10 @@ function getStoragePath(src: unknown): string { } function renderPicture( - props: CommonTransloaditImageProps, + props: TransloaditImagePresentationProps, model: Parameters[0]['model'], ): ReactNode { - return ( - - ) + return } function getStorageTransform(request: SmartCdnImageSignRequest): StorageImageTransform { diff --git a/packages/img/test/next-server.test.tsx b/packages/img/test/next-server.test.tsx index 29c3686c..bfa3da07 100644 --- a/packages/img/test/next-server.test.tsx +++ b/packages/img/test/next-server.test.tsx @@ -98,12 +98,56 @@ describe('createTransloaditImage', () => { ) const source = document.querySelector('source') - expect(source?.hasAttribute('sizes')).toBe(false) + expect(source?.getAttribute('sizes')).toBe('100vw') expect(source?.getAttribute('srcset')).toContain('200w') expect(source?.getAttribute('srcset')).toContain('400w') expect(source?.getAttribute('srcset')).toContain('800w') }) + test.each([ + 'direct', + 'redirect', + ])('preserves native attributes and descriptions in %s delivery', async (delivery) => { + const { Image } = createTransloaditImage({ + ...baseConfiguration, + storage: { + ...baseConfiguration.storage, + delivery: + delivery === 'direct' + ? 'direct' + : { authorize: () => true, route: '/api/private-images' }, + }, + }) + const document = parseMarkup( + await renderAsync( +
+ Report preview +
The annual report
+
, + ), + ) + const image = document.getElementById('report-preview') + expect(image?.getAttribute('aria-describedby')).toBe('report-caption') + expect( + document.getElementById(image?.getAttribute('aria-describedby') ?? '')?.textContent, + ).toBe('The annual report') + expect(image?.getAttribute('title')).toBe('Annual report') + expect(image?.getAttribute('role')).toBe('img') + expect(image?.getAttribute('data-document')).toBe('report') + expect(image?.getAttribute('sizes')).toBe('auto') + }) + test('rejects coercible Storage sources before signing', () => { const { Image } = createTransloaditImage(baseConfiguration) const stringConversion = vi.fn(() => 'https://assets.example/photo.jpg') @@ -189,10 +233,12 @@ describe('createTransloaditImage', () => { test('snapshots direct Storage props before crossing the request boundary', async () => { const { Image } = createTransloaditImage(baseConfiguration) let height = 300 + let id = 'original-id' let path = 'documents/report.pdf' let width = 400 connection.mockImplementationOnce(() => { height = 0 + id = 'mutated-id' path = 'private/secret.pdf' width = 0 return Promise.resolve(undefined) @@ -202,6 +248,9 @@ describe('createTransloaditImage', () => { get height() { return height }, + get id() { + return id + }, get src() { return path }, @@ -219,6 +268,7 @@ describe('createTransloaditImage', () => { expect(candidate.input).toBe('documents/report.pdf') expect(candidate.urlParams.h).toBe('300') expect(candidate.urlParams.w).toBe('400') + expect(document.querySelector('img')?.id).toBe('original-id') }) test('renders opaque authorized-route capabilities without request I/O or credentials', () => { @@ -501,13 +551,15 @@ describe('createTransloaditImage', () => { }) expect(() => - Image({ - alt: 'No suspension', - height: 300, - src: 'documents/report.pdf', - suspenseFallback: 'Loading', - width: 400, - }), + Reflect.apply(Image, undefined, [ + { + alt: 'No suspension', + height: 300, + src: 'documents/report.pdf', + suspenseFallback: 'Loading', + width: 400, + }, + ]), ).toThrow('suspenseFallback is only used by direct Storage delivery') }) diff --git a/packages/img/test/next.test.tsx b/packages/img/test/next.test.tsx index 9a3e2c1d..da52c110 100644 --- a/packages/img/test/next.test.tsx +++ b/packages/img/test/next.test.tsx @@ -5,7 +5,7 @@ import type { Root } from 'react-dom/client' import type { TransloaditImageModel } from '../src/index.ts' -import { act } from 'react' +import { act, createElement } from 'react' import { hydrateRoot } from 'react-dom/client' import { renderToStaticMarkup, renderToString } from 'react-dom/server' import { afterEach, describe, expect, test, vi } from 'vitest' @@ -45,20 +45,26 @@ function renderPicture( media: string mediaPlaceholderSrc: string preload: boolean + sizes: string + style: unknown }> = {}, ): Document { const markup = renderToStaticMarkup( - , + // Intentionally allow invalid JS prop combinations to exercise runtime guards too. + Reflect.apply(createElement, undefined, [ + TransloaditPicture, + { + alt: 'A canal house', + className: 'photo', + fetchPriority: 'high', + height: 300, + loading: 'lazy', + model, + sizes: '(min-width: 800px) 640px, 100vw', + width: 400, + ...overrides, + }, + ]), ) return new DOMParser().parseFromString(markup, 'text/html') } @@ -68,6 +74,108 @@ afterEach(() => { }) describe('TransloaditPicture', () => { + test('retains asynchronous decoding when a wrapper forwards undefined', () => { + const markup = renderToStaticMarkup( + , + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + expect(document.querySelector('img')?.getAttribute('decoding')).toBe('async') + }) + + test.each([ + 'color:red', + ['color:red'], + 123, + ])('rejects a non-object style from JavaScript: %j', (style) => { + expect(() => renderPicture({ style })).toThrow('Image style must be an object') + }) + + test('preserves serializable image attributes without exposing renderer or signing inputs', () => { + const markup = renderToStaticMarkup( + Reflect.apply(TransloaditPicture, undefined, [ + { + alt: 'A canal house', + 'aria-describedby': 'photo-caption', + authSecret: 'must-stay-private', + crossOrigin: 'anonymous', + 'data-photo': 'canal', + 'data-nonserializable': { privateValue: 'must-stay-private' }, + decoding: 'sync', + height: 300, + id: 'canal-photo', + model, + onLoad: () => undefined, + referrerPolicy: 'no-referrer', + role: 'img', + src: '/untrusted-original.jpg', + srcSet: '/untrusted-candidate.jpg 320w', + title: 'Amsterdam', + urlParams: { sig: 'must-stay-private' }, + width: 400, + }, + ]), + ) + const document = new DOMParser().parseFromString(markup, 'text/html') + const image = document.getElementById('canal-photo') + + expect(image?.getAttribute('aria-describedby')).toBe('photo-caption') + expect(image?.getAttribute('title')).toBe('Amsterdam') + expect(image?.getAttribute('role')).toBe('img') + expect(image?.getAttribute('data-photo')).toBe('canal') + expect(image?.getAttribute('decoding')).toBe('sync') + expect(image?.getAttribute('crossorigin')).toBe('anonymous') + expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(image?.getAttribute('src')).toBe(model.fallbackUrl) + expect(image?.hasAttribute('srcset')).toBe(false) + expect(markup).not.toContain('must-stay-private') + expect(markup).not.toContain('untrusted') + expect(image?.hasAttribute('model')).toBe(false) + expect(image?.hasAttribute('onload')).toBe(false) + expect(image?.hasAttribute('data-nonserializable')).toBe(false) + }) + + test('emits the browser default size explicitly when sizes is omitted', () => { + const document = renderPicture({ sizes: undefined }) + expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([ + '100vw', + '100vw', + ]) + expect(document.querySelector('img')?.hasAttribute('sizes')).toBe(false) + }) + + test.each([ + 'auto', + 'auto, 100vw', + 'AUTO, 400px', + ])('activates lazy automatic sizing for %s', (sizes) => { + const document = renderPicture({ sizes }) + expect([...document.querySelectorAll('source')].map((source) => source.sizes)).toEqual([ + sizes, + sizes, + ]) + expect(document.querySelector('img')?.getAttribute('sizes')).toBe('auto') + expect(document.querySelector('img')?.getAttribute('loading')).toBe('lazy') + }) + + test.each([ + 'auto', + 'auto, 100vw', + 'AUTO, 400px', + ])('rejects eager automatic sizing for %s', (sizes) => { + expect(() => renderPicture({ loading: 'eager', sizes })).toThrow( + 'Automatic image sizes require lazy loading', + ) + expect(() => renderPicture({ loading: undefined, preload: true, sizes })).toThrow( + 'Automatic image sizes require lazy loading', + ) + }) + test('renders native picture sources and the supplied fallback', () => { const document = renderPicture() const sources = [...document.querySelectorAll('source')] @@ -132,6 +240,31 @@ describe('TransloaditPicture', () => { expect(image?.getAttribute('src')).toBe(model.fallbackUrl) }) + test('uses the same request policy on the preload and the image', () => { + const document = new DOMParser().parseFromString( + renderToStaticMarkup( + , + ), + 'text/html', + ) + const preload = document.querySelector('link[rel="preload"]') + const image = document.querySelector('img') + + expect(preload?.getAttribute('crossorigin')).toBe('use-credentials') + expect(preload?.getAttribute('referrerpolicy')).toBe('no-referrer') + expect(preload?.getAttribute('imagesizes')).toBe('100vw') + expect(image?.getAttribute('crossorigin')).toBe('use-credentials') + expect(image?.getAttribute('referrerpolicy')).toBe('no-referrer') + }) + test('rejects a media-gated preload instead of letting React deduplicate it incorrectly', () => { expect(() => renderPicture({ loading: 'eager', media: '(min-width: 768px)', preload: true }), diff --git a/packages/img/test/types.tsx b/packages/img/test/types.tsx index 9f52ca55..14757209 100644 --- a/packages/img/test/types.tsx +++ b/packages/img/test/types.tsx @@ -7,6 +7,8 @@ import type { } from '../src/next/server.tsx' import { createTransloaditImageModel } from '../src/index.ts' +import { TransloaditPicture } from '../src/next/index.tsx' +import { createTransloaditImage } from '../src/next/server.tsx' const modelOptions: TransloaditImageModelOptions = { expiresAt: Date.UTC(2030, 0, 1), @@ -16,12 +18,12 @@ const modelOptions: TransloaditImageModelOptions = { width: 400, } -const imageProps: TransloaditImageProps = { +const imageProps = { alt: 'Preview of report.pdf', height: 300, src: 'documents/report.pdf', width: 400, -} +} satisfies TransloaditImageProps // @ts-expect-error Storage preview formats use format-specific quality values, not a tuple. const modelWithTuple: TransloaditImageModelOptions = { ...modelOptions, formats: ['webp'] } @@ -46,6 +48,42 @@ const image = Image(imageProps) const directImage = direct.Image(imageProps) const redirectedImage = redirect.Image(imageProps) const routeResponse = redirect.storageRoute(new Request('https://app.example/images')) +const attributedImage = ( + +) +const eagerImage = +const lazyImage = +// @ts-expect-error A preloaded image cannot be lazy. +const lazyPreload = +const lazyPicturePreload = ( + // @ts-expect-error The model-only renderer also rejects a lazy preload. + +) +// @ts-expect-error A redirect image never suspends for signing. +const redirectFallback = +// @ts-expect-error Event callbacks are not serializable image attributes. +const callbackImage = undefined} /> +// @ts-expect-error Candidate URLs belong to the configured image model. +const customSourceSet = +// @ts-expect-error Signing policy belongs to the server-only factory. +const perImageSecret = +const configuredRedirect = createTransloaditImage({ + authKey: 'key', + authSecret: 'secret', + workspace: 'app', + storage: { delivery: { route: '/images', authorize: () => true } }, +}) +const configuredRedirectFallback = ( + // @ts-expect-error Factory overloads retain the redirect-specific component contract. + +) // @ts-expect-error Direct integrations do not expose an authorization route. const missingRoute = direct.storageRoute // @ts-expect-error Storage previews always use their signed JPEG fallback. @@ -65,3 +103,13 @@ void model void modelWithTuple void redirectedImage void routeResponse +void attributedImage +void eagerImage +void lazyImage +void lazyPreload +void lazyPicturePreload +void redirectFallback +void callbackImage +void customSourceSet +void perImageSecret +void configuredRedirectFallback