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
47 changes: 39 additions & 8 deletions packages/img/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,35 @@ export default function Page() {
<Image
alt="A canal house"
height={1600}
sizes="(min-width: 1024px) 960px, 100vw"
sizes="(min-width: 960px) 960px, 100vw"
src="website/canal-house.jpg"
style={{ display: 'block', height: 'auto', maxWidth: 960, width: '100%' }}
width={2400}
/>
)
}
```

The 2400×1600 dimensions describe the source, not a 2400px display box. The CSS caps the hero at
960px, preserves its 3:2 aspect ratio, and lets it shrink with its container. `sizes` describes
that layout to the browser; it does not set CSS dimensions. Adjust it if your page has gutters or
a narrower container.

For a 400×400 avatar source displayed in a 48px box, limit the candidates to 1× and 2×:

```tsx
<Image
alt="Your profile photo"
height={400}
objectFit="cover"
sizes="48px"
src="website/avatar.jpg"
style={{ display: 'block', height: 48, width: 48 }}
width={400}
widths={[48, 96]}
/>
```

`storage.allowedPathPrefixes` is a hard workspace boundary, not object authorization. Prefixes must
be relative directories ending in `/`. The default is deny-all; `['']` deliberately allows the
workspace root. Paths with dot segments, backslashes, empty segments, control characters,
Expand All @@ -68,7 +89,9 @@ Direct delivery is the default and fits image-heavy views that already authorize
rendering. The component calls Next.js `connection()` before creating short-lived signed URLs. A
built-in Suspense boundary lets a Cache Components page prerender a shell, but the signed image
itself is request-rendered and must not be stored in a shared full-page cache.
`suspenseFallback` customizes that shell.
By default the shell reserves the image's dimensions and layout styles with an inert, invisible
image that has no source and makes no request. `suspenseFallback` explicitly replaces that shell
(including `null` to omit it); custom fallbacks must reserve their own space.

The browser requests the selected candidate directly from Smart CDN. Lazy loading remains the
platform default. A candidate first requested after its signature expires can fail on an unusually
Expand Down Expand Up @@ -135,16 +158,18 @@ CDN objects independent from an unkeyed `Accept` header.

The default candidate ladder is 320, 640, 960, 1280, 1920, 2560, and 3840 pixels, capped at the
declared intrinsic width and backend-safe height. The exact intrinsic width is included between
steps. `widths` is an advanced per-image override. `sizes` is optional because that is valid HTML,
but strongly recommended whenever an image is not effectively `100vw`.
steps. `widths` is an advanced per-image override. Omitted `sizes` emits explicit `100vw` on the
width-based sources. Supply the actual display width when it differs, or use `sizes="auto, 100vw"`
for a lazy image whose size should come from its CSS box. Automatic sizes cannot be eager or preloaded.

```tsx
<Image
alt="Product photo"
formats={{ avif: 40, webp: 70 }}
height={1200}
sizes="(min-width: 1280px) 600px, 50vw"
sizes="(min-width: 1200px) 600px, 50vw"
src="website/products/photo.jpg"
style={{ display: 'block', height: 'auto', maxWidth: 600, width: '50vw' }}
width={1600}
widths={[400, 800, 1200, 1600]}
/>
Expand All @@ -153,9 +178,8 @@ but strongly recommended whenever an image is not effectively `100vw`.
- Images are lazy and asynchronously decoded by default.
- `preload` implies eager loading. Combine it with `fetchPriority="high"` only for a measured LCP
image. Explicitly lazy preloads are rejected.
- `objectFit` is forwarded for deliberate crop or containment behavior.
- `deferUntilHydrated` avoids WebKit parser-to-hydration replay for non-critical images. It cannot be
eager or preloaded and is not a secrecy mechanism.
- Keep `width`/`height` in the source's proportions. Transforms use `r: 'pad'`; CSS `objectFit`
controls cropping in a display box but cannot undo padding already encoded in the image.
- `fallbackQuality` changes the signed JPEG fallback quality.

Private signature lifetimes default to at least one hour in stable five-minute rotation windows.
Expand Down Expand Up @@ -186,6 +210,13 @@ export const { Image } = createTransloaditImage({
Template selection is unavailable on individual images because the factory owns the signing
boundary. A replacement must accept the same trusted fields as the Storage preview Built-in.

### Opt-in hydration workaround

`deferUntilHydrated` avoids WebKit parser-to-hydration request replay for non-critical images.
Leave it off unless you have observed that problem: it delays candidate markup until hydration,
and its initial `<noscript>` fallback does not reserve space for JavaScript-enabled browsers.
It cannot be eager or preloaded and is not a secrecy mechanism.

## Framework-neutral API

`@transloadit/img` exports `createTransloaditImageModel` and serializable model types.
Expand Down
26 changes: 25 additions & 1 deletion packages/img/src/next/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ interface TransloaditStorageImageRequestProps {
props: TransloaditImageProps
}

function StorageImagePlaceholder({ props }: TransloaditStorageImageRequestProps): ReactNode {
const attributes = snapshotImageAttributes(props)
return (
<picture>
<img
{...attributes}
alt=""
aria-hidden="true"
inert
sizes={undefined}
style={{ ...attributes.style, visibility: 'hidden' }}
/>
</picture>
)
}

function validateRequiredConfiguration(value: string, name: string): void {
if (typeof value !== 'string' || value === '' || value.trim() !== value) {
throw new TypeError(`${name} must be a non-empty string without surrounding whitespace`)
Expand Down Expand Up @@ -617,7 +633,15 @@ export function createTransloaditImage(
const storageProps = snapshotStorageImageProps(props, storagePath)
if (storageCapability === undefined) {
return (
<Suspense fallback={props.suspenseFallback}>
<Suspense
fallback={
storageProps.suspenseFallback === undefined ? (
<StorageImagePlaceholder props={storageProps} />
) : (
storageProps.suspenseFallback
)
}
>
<DirectStorageImage props={storageProps} />
</Suspense>
)
Expand Down
92 changes: 92 additions & 0 deletions packages/img/test/next-server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,98 @@ afterEach(() => {
})

describe('createTransloaditImage', () => {
test('reserves native image geometry while request-time signing is suspended', async () => {
let resolveConnection: (value: undefined) => void = () => {
throw new Error('Connection was not initialized')
}
const pending = new Promise<undefined>((resolve) => {
resolveConnection = resolve
})
connection.mockImplementationOnce(() => pending)
const { Image } = createTransloaditImage(baseConfiguration)
const stream = await renderToReadableStream(
<main>
<Image
alt="Hero"
className="hero"
height={1600}
id="hero"
preload
sizes="(min-width: 960px) 960px, 100vw"
src="documents/hero.jpg"
style={{ display: 'block', height: 'auto', maxWidth: 960, width: '100%' }}
width={2400}
/>
<p>Following content</p>
</main>,
)
const reader = stream.getReader()
const shell = new TextDecoder().decode((await reader.read()).value)
const placeholder = parseMarkup(shell).getElementById('hero')
// Always resolve the request so a failed assertion cannot leak a suspended stream.
resolveConnection(undefined)
await stream.allReady
let remaining = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
remaining += new TextDecoder().decode(value)
}
const image = parseMarkup(remaining).getElementById('hero')

expect(placeholder?.getAttribute('width')).toBe('2400')
expect(placeholder?.getAttribute('height')).toBe('1600')
expect(placeholder?.getAttribute('class')).toBe('hero')
// Consumer selectors such as picture > img must apply before signing resolves too.
expect(placeholder?.parentElement?.tagName).toBe('PICTURE')
expect(placeholder?.getAttribute('style')).toBe(
'display:block;height:auto;max-width:960px;width:100%;visibility:hidden',
)
expect(placeholder?.getAttribute('aria-hidden')).toBe('true')
expect(placeholder?.hasAttribute('inert')).toBe(true)
expect(placeholder?.hasAttribute('src')).toBe(false)
expect(shell).not.toContain('cdn.example')
expect(shell).not.toContain('imageSrcSet')
expect(shell).toContain('Following content')
expect(image?.getAttribute('style')).toBe(
'display:block;height:auto;max-width:960px;width:100%',
)
expect(image?.getAttribute('width')).toBe('2400')
expect(image?.getAttribute('height')).toBe('1600')
expect(image?.getAttribute('src')).toContain('cdn.example')
})

test('keeps an explicit direct Suspense fallback as an override', async () => {
let resolveConnection: (value: undefined) => void = () => {
throw new Error('Connection was not initialized')
}
const pending = new Promise<undefined>((resolve) => {
resolveConnection = resolve
})
connection.mockImplementationOnce(() => pending)
const { Image } = createTransloaditImage(baseConfiguration)
const stream = await renderToReadableStream(
<main>
<Image
alt="Custom shell"
height={300}
src="documents/report.pdf"
suspenseFallback={<p role="status">Custom preview</p>}
width={400}
/>
<p>Following content</p>
</main>,
)
const reader = stream.getReader()
const shell = new TextDecoder().decode((await reader.read()).value)
resolveConnection(undefined)
await stream.allReady
await reader.cancel()

expect(parseMarkup(shell).querySelector('[role="status"]')?.textContent).toBe('Custom preview')
expect(parseMarkup(shell).querySelector('img')).toBeNull()
})

test('allows explicit widths while making sizes optional', async () => {
const { Image } = createTransloaditImage(baseConfiguration)
const document = parseMarkup(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default async function Page({ params }: PageProps): Promise<ReactNode> {
key={index}
sizes="200px"
src={`documents/benchmark-${index + 1}.jpg`}
style={{ display: 'block', height: 'auto', width: 200 }}
width={400}
/>,
)
Expand Down
3 changes: 3 additions & 0 deletions scripts/fixtures/img-next/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps): ReactNode {
return (
<html lang="en">
<head>
<style>{'picture > img.hero {display:block;height:auto;max-width:960px;width:100%}'}</style>
</head>
<body>{children}</body>
</html>
)
Expand Down
32 changes: 24 additions & 8 deletions scripts/fixtures/img-next/app/storage-image/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@ import { TransloaditImage } from '../TransloaditImage.tsx'

export default function Page(): ReactNode {
return (
<TransloaditImage
alt="Storage fixture"
height={300}
sizes="400px"
src="documents/report.pdf"
suspenseFallback={<div aria-label="Loading preview" role="status" />}
width={400}
/>
<main>
<TransloaditImage
alt="Storage hero"
className="hero"
height={1600}
id="hero"
sizes="(min-width: 960px) 960px, 100vw"
src="documents/hero.jpg"
width={2400}
/>
<p>After the hero</p>
<TransloaditImage
alt="Storage avatar"
height={400}
id="avatar"
objectFit="cover"
sizes="48px"
src="documents/avatar.jpg"
style={{ display: 'block', height: 48, width: 48 }}
width={400}
widths={[48, 96]}
/>
<p>After the avatar</p>
</main>
)
}
9 changes: 5 additions & 4 deletions scripts/fixtures/img-next/app/storage-redirect/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ export default function Page(): ReactNode {
<TransloaditRedirectImage
alt="Authorized Storage fixture"
fetchPriority="high"
height={300}
height={1600}
preload
sizes="400px"
src="documents/report.pdf"
width={400}
sizes="(min-width: 960px) 960px, 100vw"
src="documents/hero.jpg"
style={{ display: 'block', height: 'auto', maxWidth: 960, width: '100%' }}
width={2400}
/>
)
}
26 changes: 24 additions & 2 deletions scripts/test-img-next-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ function getFirstPictureCandidates(html: string): string[] {
const candidates: string[] = []
for (const picture of pictures) {
const sourceSet = /<source\b[^>]*\bsrcset="([^"]+)"/i.exec(picture)?.[1]
assert(sourceSet !== undefined, 'Expected every benchmark picture to contain a source set')
// Streamed HTML also contains source-free Suspense placeholders; count only resolved images.
if (sourceSet === undefined) continue
const decoded = decodeHtmlAttribute(sourceSet)
const separator = decoded.indexOf(' ')
assert(separator > 0, 'Expected every benchmark candidate to have a width descriptor')
Expand Down Expand Up @@ -267,7 +268,23 @@ async function main(): Promise<void> {
'Expected redirect-delivery markup to prerender',
)
const storageShell = await readFile(resolve(appOutput, 'storage-image.html'), 'utf8')
assert(storageShell.includes('Loading preview'), 'Storage shell fallback is absent')
assert(
/<picture><img\b/.test(storageShell),
'Storage placeholder does not retain picture-based CSS selectors',
)
assert(
storageShell.includes('visibility:hidden'),
'Storage shell does not reserve image layout',
)
assert(
storageShell.includes('width="2400"') && storageShell.includes('height="1600"'),
'Hero source dimensions are absent',
)
assert(
storageShell.includes('height:auto;max-width:960px;width:100%'),
'Hero responsive CSS is absent',
)
assert(storageShell.includes('height:48px;width:48px'), 'Avatar CSS box is absent')
assert(
!storageShell.includes('builtin%2Fstorage-preview%400.0.1'),
'A signed Storage URL leaked into the prerendered shell',
Expand Down Expand Up @@ -309,6 +326,11 @@ async function main(): Promise<void> {
)
assert(!storageHtml.includes(fixtureSecret), 'Secret leaked into Storage output')
assert(!redirectHtml.includes(fixtureSecret), 'Secret leaked into redirect output')
assert(
storageHtml.includes(' 48w') && storageHtml.includes(' 96w'),
'Avatar candidates are absent',
)
assert(storageHtml.includes('sizes="48px"'), 'Avatar sizes are absent')

const routeCandidate = getFirstPictureCandidates(redirectHtml)[0]
assert(routeCandidate !== undefined, 'Expected a redirect route candidate')
Expand Down