Skip to content
Merged
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
14 changes: 13 additions & 1 deletion docs/01-app/02-guides/videos.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ The HTML [`<video>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/v
```jsx filename="app/ui/video.jsx"
export function Video() {
return (
<video width="320" height="240" controls preload="none">
<video
width="320"
height="240"
poster="/path/to/poster.jpg"
controls
preload="none"
>
<source src="/path/to/video.mp4" type="video/mp4" />
<track
src="/path/to/captions.vtt"
Expand All @@ -42,6 +48,7 @@ export function Video() {
| `autoPlay` | Automatically starts playing the video when the page loads. Note: Autoplay policies vary across browsers. | `<video autoPlay />` |
| `loop` | Loops the video playback. | `<video loop />` |
| `muted` | Mutes the audio by default. Often used with `autoPlay`. | `<video muted />` |
| `poster` | An image shown in the video's box until the first frame is available. | `<video poster="/poster.jpg" />` |
| `preload` | Specifies how the video is preloaded. Values: `none`, `metadata`, `auto`. | `<video preload="none" />` |
| `playsInline` | Enables inline playback on iOS devices, often necessary for autoplay to work on iOS Safari. | `<video playsInline />` |

Expand All @@ -51,6 +58,8 @@ For a comprehensive list of video attributes, refer to the [MDN documentation](h

### Video best practices

- **Dimensions:** Set `width` and `height`, or a CSS `aspect-ratio`, so the browser reserves the box before the file loads. A video without dimensions collapses and then pushes the rest of the page down, which counts against [Cumulative Layout Shift](https://web.dev/articles/cls).
- **Poster Image:** Use `poster` to fill that reserved box while the video loads, especially with `preload="none"`, where no frame is fetched until playback starts.
- **Fallback Content:** When using the `<video>` tag, include fallback content inside the tag for browsers that do not support video playback.
- **Subtitles or Captions:** Include subtitles or captions for users who are deaf or hard of hearing. Utilize the [`<track>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/track) tag with your `<video>` elements to specify caption file sources.
- **Accessible Controls:** Standard HTML5 video controls are recommended for keyboard navigation and screen reader compatibility. For advanced needs, consider third-party players like [react-player](https://github.com/cookpete/react-player) or [video.js](https://videojs.com/), which offer accessible controls and consistent browser experience.
Expand All @@ -77,8 +86,11 @@ export default function Page() {
| `allowFullScreen` | Allows the iframe content to be displayed in full-screen mode. | `<iframe allowFullScreen />` |
| `sandbox` | Enables an extra set of restrictions on the content within the iframe. | `<iframe sandbox />` |
| `loading` | Optimize loading behavior (e.g., lazy loading). | `<iframe loading="lazy" />` |
| `style` | Apply CSS, such as an `aspect-ratio` to keep the box a fixed shape. | `<iframe style={{ border: 0 }} />` |
| `title` | Provides a title for the iframe to support accessibility. | `<iframe title="Description" />` |

An iframe without dimensions collapses until its content loads, the same as a video. Give it `width` and `height` or an `aspect-ratio`, particularly with `loading="lazy"`, where the embed can resolve long after the surrounding page has painted.

For a comprehensive list of iframe attributes, refer to the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attributes).

### Choosing a video embedding method
Expand Down

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ const CACHE_VERSION = 4
const BLUR_IMG_SIZE = 8 // should match `next-image-loader`
const BLUR_QUALITY = 70 // should match `next-image-loader`

function isValidMime(contentType: string) {
return Boolean(getExtension(contentType))
}

async function initCacheEntries(
cacheDir: string
): Promise<Array<{ key: string; size: number; expireAt: number }>> {
Expand Down Expand Up @@ -769,7 +765,6 @@ export async function imageOptimizer(
)

return imageOptimizerTransform(imageUpstream, paramsResult, nextConfig, {
isValidMime,
previousOutput: previouslyCachedImage
? {
buffer: previouslyCachedImage.buffer,
Expand Down
37 changes: 13 additions & 24 deletions packages/next/src/server/image-optimizer/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ export interface ImageOptimizerTransformLogger {
}

export interface ImageOptimizerTransformOptions {
isValidMime: (contentType: string) => boolean
previousOutput?: {
buffer: Buffer
maxAge?: number
Expand Down Expand Up @@ -195,7 +194,7 @@ export async function imageOptimizerTransform(
imageUpstream: ImageUpstream,
paramsResult: ImageOptimizerTransformParams,
nextConfig: ImageOptimizerTransformConfig,
opts: ImageOptimizerTransformOptions
opts: ImageOptimizerTransformOptions = {}
): Promise<ImageOptimizerResult> {
const { href, quality, width, mimeType } = paramsResult
const { buffer: upstreamBuffer, etag: upstreamEtag } = imageUpstream
Expand Down Expand Up @@ -256,14 +255,11 @@ export async function imageOptimizerTransform(

if (mimeType) {
contentType = mimeType
} else if (
opts.isValidMime(upstreamType) &&
upstreamType !== WEBP &&
upstreamType !== AVIF
) {
contentType = upstreamType
} else {
} else if (upstreamType === WEBP || upstreamType === AVIF) {
// Downlevel WebP and AVIF when the client does not advertise support.
contentType = JPEG
} else {
contentType = upstreamType
}

if (opts.previousOutput) {
Expand Down Expand Up @@ -301,21 +297,14 @@ export async function imageOptimizerTransform(
upstreamEtag,
}
} catch (error) {
if (upstreamType) {
// If we fail to optimize, fallback to the original image
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge: nextConfig.images.minimumCacheTTL,
etag: upstreamEtag,
upstreamEtag,
error,
}
} else {
throw new ImageError(
400,
'Unable to optimize image and unable to fallback to upstream image'
)
// If we fail to optimize, fallback to the original image
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge: nextConfig.images.minimumCacheTTL,
etag: upstreamEtag,
upstreamEtag,
error,
}
}
}
14 changes: 14 additions & 0 deletions packages/next/src/server/lib/cache-handlers/default.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ setFlagsFromString('--expose-gc')
const forceGarbageCollection = runInNewContext('gc') as () => void

describe('default use cache handler', () => {
/**
* These tests guard the handler against retaining the request that filled or
* read an entry. Whether a regression here fails depends on the
* `AsyncLocalStorage` implementation of the environment:
*
* - Node 20 and 22 attach the active store to every promise, so a retained
* stream keeps the store reachable, and these tests fail on a regression.
* - Node 24 and later use `AsyncContextFrame`, and these tests pass with or
* without the retention.
*
* CI runs Node 20.9, so the guard holds there. A regression is invisible to a
* developer who runs the suite on Node 24 or later.
*/

it('does not retain the async context that populated an entry', async () => {
const handler = createDefaultCacheHandler(1024 * 1024)
const requestStoreRef = await runInRequestContext(() =>
Expand Down
49 changes: 43 additions & 6 deletions packages/next/src/server/lib/cache-handlers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,24 @@ export type Timestamp = number

export interface CacheEntry {
/**
* The ReadableStream can error and only have partial data so any cache
* handlers need to handle this case and decide to keep the partial cache
* around or not.
* The serialized value of the entry. A cache handler consumes this stream in
* `set` and persists what it delivers. A handler can buffer the stream, or it
* can pipe it to its storage as chunks arrive. Two rules apply either way:
*
* - A handler must return a new stream from every `get`.
* - A handler must not retain the stream after `set` resolves.
*
* A stream can be read one time only. A second reader of the same stream gets
* no data, or an error while the first reader holds the lock. A retained
* stream also keeps a reference to the async context of the request that
* created it, which keeps the state of that request reachable for as long as
* the entry lives.
*
* The same applies to the `pendingEntry` promise that `set` receives. A
* handler must not put that promise in a map that outlives the request.
*
* The stream can error and deliver partial data. Each handler decides whether
* it keeps the partial entry or discards it.
*/
value: ReadableStream<Uint8Array>

Expand All @@ -30,19 +45,33 @@ export interface CacheEntry {
/**
* How long the entry is allowed to be used (should be longer than revalidate)
* [duration in seconds]
*
* This is the hard limit. Next.js compares it against `timestamp` on every
* read and treats a too-old entry as a miss, so a handler does not need to
* check the age of an entry before it returns one. The dev server raises the
* limit to five minutes when `expire` is shorter, to keep reloads fast.
*/
expire: number

/**
* How long until the entry should be revalidated [duration in seconds]
*
* An entry that is past `revalidate` but within `expire` is still served, and
* Next.js generates a fresh one in the background. A negative value always
* lies in the past, so it forces that background refresh on the next read.
* The built-in handler returns `-1` for an entry whose tag is stale, which
* serves the entry one more time and replaces it.
*/
revalidate: number
}

export interface CacheHandler {
/**
* Retrieve a cache entry for the given cache key, if available. Will return
* undefined if there's no valid entry, or if the given soft tags are stale.
* undefined if there's nothing stored, or if the given soft tags are stale.
*
* Each call returns a new `value` stream over the stored bytes. See
* `CacheEntry.value`.
*/
get(cacheKey: string, softTags: string[]): Promise<undefined | CacheEntry>

Expand All @@ -53,12 +82,20 @@ export interface CacheHandler {
* before the pending entry is complete, the cache handler must wait for the
* `set` operation to finish, before returning the entry, instead of returning
* undefined.
*
* The handler takes ownership of the entry's `value` stream, and consumes it
* exactly once. See `CacheEntry.value`.
*
* The handler also owns eviction. Next.js never removes an entry from the
* store, so the store needs a mechanism of its own, such as a time to live
* from `expire`, or a size-bounded LRU.
*/
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>

/**
* This function may be called periodically, but always before starting a new
* request. If applicable, it should communicate with the tags service to
* This function is called once per request, before the first cache read for
* this handler's kind. A request that reads nothing from this handler does
* not call it. If applicable, it should communicate with the tags service to
* refresh the local tags manifest accordingly.
*/
refreshTags(): Promise<void>
Expand Down
11 changes: 11 additions & 0 deletions packages/next/src/server/stream-utils/node-web-streams-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export function streamFromString(str: string): ReadableStream<Uint8Array> {
})
}

/**
* Creates a stream that delivers `chunk` and then closes.
*
* The stream is a default stream, and it has to stay one. A byte stream (`type:
* 'bytes'`) transfers the buffer of each chunk that it receives, which detaches
* `chunk`. A caller that keeps `chunk` to serve more than one read, such as an
* in-memory cache handler, loses the data on the first read.
*
* Every reader receives the same `chunk` instance. A reader that modifies it
* changes what later readers see.
*/
export function streamFromBuffer(chunk: Buffer): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
Expand Down
10 changes: 6 additions & 4 deletions packages/next/src/server/use-cache/tiered-cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,12 @@ async function reconcileFrontFromBacking(
if (backingEntry.timestamp > frontEntry.timestamp) {
await front.set(cacheKey, Promise.resolve(backingEntry))
} else {
// The front is already up to date, so the backing entry goes unused.
// Release its stream without awaiting: a teed stream's `cancel()` only
// settles once the sibling branch (retained by the backing handler) is
// also cancelled, so awaiting it here would hang the reconcile.
// The front is already up to date, so the backing entry goes unused. This
// code releases its stream and does not await the result. The backing
// handler is user-configured, and it can return one branch of a teed
// stream. `cancel()` on such a branch settles only after the sibling
// branch is cancelled too, and the handler retains that sibling, so an
// await here would hang the reconcile.
void backingEntry.value.cancel()
}
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const crypto = require('node:crypto')

const cacheDir = path.join(__dirname, '.file-system-cache')

/** @param {string} cacheKey */
function filePathForKey(cacheKey) {
const hash = crypto.createHash('sha256').update(cacheKey).digest('hex')
return path.join(cacheDir, `${hash}.json`)
Expand Down
4 changes: 1 addition & 3 deletions test/e2e/app-dir/use-cache-custom-handler/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ const cacheHandler = {

async getExpiration(tags) {
console.log('ModernCustomCacheHandler::getExpiration', JSON.stringify(tags))
// Expecting soft tags in `get` to be used by the cache handler for checking
// the expiration of a cache entry, instead of letting Next.js handle it.
return Infinity
return defaultCacheHandler.getExpiration(tags)
},

async updateTags(tags) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const cacheHandler = {

async getExpiration(tags) {
console.log('WiringModernCacheHandler::getExpiration', JSON.stringify(tags))
return Infinity
return defaultCacheHandler.getExpiration(tags)
},

async updateTags(tags) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
globalThis.noop = () => {}

export async function getData(action) {
'use cache: remote'

console.log(action)
// Pretend to use it
globalThis.noop(action)

return Math.random()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { connection } from 'next/server'
import { action } from './action'
import { getData } from './get-data'

export const instant = false

export default async function Page() {
await connection()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
'use client'

export function client() {
return 'first'
import { useState } from 'react'

export function Client() {
const [text, setText] = useState('')

return (
<div>
<div id="title">Client Component A</div>
<button
onClick={() => {
setText('Button clicked')
}}
>
Click me
</button>
<span id="state">{text}</span>
</div>
)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export async function getData(Client) {
'use cache: remote'

return (
<div>
<span id="data">{Math.random()}</span>
<Client />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { connection } from 'next/server'
import { client } from './client'
import { Client } from './client'
import { getData } from './get-data'

export const instant = false

export default async function Page() {
await connection()

return <span id="data">{await getData(client)}</span>
return <div>{await getData(Client)}</div>
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
globalThis.noop = () => {}

export async function getData(action) {
'use cache: remote'

console.log(action)
// Pretend to use it
globalThis.noop(action)

return Math.random()
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { connection } from 'next/server'
import { action } from './action'
import { getData } from './get-data'

export const instant = false

export default async function Page() {
await connection()

Expand Down
Loading
Loading