diff --git a/src/content/reference/react-dom/components/img.md b/src/content/reference/react-dom/components/img.md index 4a1f45077b8..cb6df0d4509 100644 --- a/src/content/reference/react-dom/components/img.md +++ b/src/content/reference/react-dom/components/img.md @@ -125,22 +125,23 @@ To create an explicit preload hint, call [`preload`](/reference/react-dom/preloa ### Waiting for an image during a View Transition {/*waiting-for-an-image-during-a-view-transition*/} -During a client-rendered [``](/reference/react/ViewTransition) update, React may wait for an image to load and decode before starting the animation. This applies when a new `` with a non-empty `src` is rendered, or when an existing image's `src` or `srcSet` changes. The image must be inside the `` subtree and must not have `loading="lazy"` or an `onLoad` handler. React does not wait for images during synchronous updates. +During a Suspense reveal inside a [``](/reference/react/ViewTransition), React waits up to 500 ms for visible images to load and decode before starting the animation. This includes newly rendered `` elements with a non-empty `src` and existing images whose `src` or `srcSet` changes. React does not wait for images with `loading="lazy"` or an `onLoad` handler. -When a Suspense boundary reveals streamed content inside a ``, React may also wait for visible images with a non-empty `src` that do not have `loading="lazy"`. React stops waiting after a timeout so that a slow image does not block the update indefinitely. - -In this example, the Suspense boundary is wrapped in a `` and shows a profile skeleton until the portrait has loaded. - -For comparison, the second button inserts the same card directly into the DOM. The card appears immediately, and the browser displays the image after it loads: +Compare how the same image appears when a Suspense boundary reveals its content inside and outside a ``: ```js -import { ViewTransition, Suspense, useState, startTransition } from 'react'; -import { freshImageUrl } from './image.js'; -import VanillaProfile from './VanillaProfile.js'; - -function Profile({ src }) { +import { + ViewTransition, + Suspense, + use, + useState, +} from 'react'; +import { fetchImageSrc } from './image.js'; + +function Profile({ cacheKey }) { + const src = use(fetchImageSrc(cacheKey)); return (
Jack Pope @@ -159,56 +160,51 @@ function ProfilePlaceholder() { } export default function App() { - const [src, setSrc] = useState(null); + const [showWithTransition, setShowWithTransition] = + useState(false); + const [showWithoutTransition, setShowWithoutTransition] = + useState(false); return ( <> - - {src && ( + {showWithTransition && ( }> - + )}
- + + {showWithoutTransition && ( + }> + + + )} ); } ``` -```js src/VanillaProfile.js -import { useRef } from 'react'; -import { freshImageUrl } from './image.js'; +```js src/image.js hidden +// Normally, the caching logic would be inside a framework. +const cache = new Map(); -export default function VanillaProfile() { - const ref = useRef(null); - function show() { - ref.current.innerHTML = `
- Jack Pope -

Jack Pope

-
`; +export function fetchImageSrc(cacheKey) { + if (!cache.has(cacheKey)) { + cache.set(cacheKey, loadImageSrc()); } - return ( - <> - -
- - ); + return cache.get(cacheKey); } -``` -```js src/image.js hidden -// Add a unique parameter so the image isn't cached, -// and every run shows the loading state. -export function freshImageUrl() { +async function loadImageSrc() { + // Delay the response so the Suspense fallback is visible. + await new Promise(resolve => setTimeout(resolve, 1000)); + // Add a unique parameter so the image isn't cached. return 'https://react.dev/images/team/jack-pope.jpg?t=' + Date.now(); } ``` @@ -255,3 +251,5 @@ hr { ``` + +With ``, React keeps the skeleton visible for up to 500 ms while the image loads, so the card can be revealed with its image already in place. Without ``, Suspense stops showing the skeleton as soon as the Promise resolves. If the image is still loading, the card appears first and the image pops in afterward. diff --git a/src/content/reference/react/Suspense.md b/src/content/reference/react/Suspense.md index 18b20816f8f..50039d4f561 100644 --- a/src/content/reference/react/Suspense.md +++ b/src/content/reference/react/Suspense.md @@ -47,8 +47,8 @@ A Suspense boundary waits for its content to be ready before revealing it. Any o - Reading a Promise with [`use`](/reference/react/use), including data streamed from [Server Components](/reference/rsc/server-components) or loaded through a [Suspense-enabled framework](#suspense-enabled-frameworks). - Loading a stylesheet rendered with [`` and a `precedence` prop.](/reference/react-dom/components/link#special-rendering-behavior) React blocks the boundary until the stylesheet loads, up to a timeout. [See an example below.](#waiting-for-a-stylesheet-to-load) - Waiting for a large boundary's HTML to arrive during streaming server rendering. Sending HTML takes time, so a boundary with enough content activates even when nothing in it suspends. React reveals the content as the HTML arrives. -- Loading fonts. Suspense doesn't wait for fonts by default, but a [``](/reference/react/ViewTransition) update waits for new fonts to load, up to a timeout, so text doesn't flash with a fallback font. [See an example below.](#waiting-for-a-font-to-load) -- Loading images. Suspense doesn't wait for images by default, but during a [``](/reference/react/ViewTransition) update, React blocks the boundary until the image loads, up to a timeout. Adding an `onLoad` handler opts a specific image out. [See an example below.](#waiting-for-an-image-to-load) +- Loading fonts. Suspense doesn't wait for fonts by default, but a [``](/reference/react/ViewTransition) update waits up to 500 ms for new fonts to load so text doesn't flash with a fallback font. [See an example below.](#waiting-for-a-font-to-load) +- Loading images. Suspense doesn't wait for images by default, but during a [``](/reference/react/ViewTransition) update, React blocks the boundary for up to 500 ms while the image loads. Adding an `onLoad` handler opts a specific image out. [See an example below.](#waiting-for-an-image-to-load) - Performing CPU-bound render work inside a [``](#props) boundary. @@ -2901,52 +2901,59 @@ Where you place the `` relative to the boundary determines wheth ### Waiting for a font to load {/*waiting-for-a-font-to-load*/} -When a [``](/reference/react/ViewTransition) animates a Suspense boundary's reveal, React waits for new fonts the content introduces, up to a timeout, so the text doesn't flash with a fallback font. This only happens during a `` update. +When a [``](/reference/react/ViewTransition) animates a Suspense boundary's reveal, React waits up to 500 ms for new fonts the content introduces so the text doesn't flash with a fallback font. This only happens during a `` update. -In the example below, the Suspense boundary is wrapped in a ``, and the `Quote` component suspends while its data loads. Rendering the quote starts its font download. React keeps the fallback visible until the font has loaded, so the quote appears already in its font. +In the example below, the Suspense boundary is wrapped in a ``, and the `Quote` component suspends while its data loads. Rendering the quote loads a stylesheet that introduces the font. React keeps the fallback visible while the stylesheet and font load. If the font loads within the 500 ms limit, the quote appears already in its font. For comparison, the second button performs the same update without React. Nothing waits for the font, so the text appears in a fallback font first and then switches: ```js -import { ViewTransition, Suspense, use, useState, startTransition } from 'react'; +import { + ViewTransition, + Suspense, + use, + useState, + startTransition, +} from 'react'; import { fetchQuote } from './data.js'; -import { freshFontUrl } from './font.js'; +import { + freshFontUrl, + freshStylesheetUrl, +} from './font.js'; import VanillaQuote from './VanillaQuote.js'; -function Quote({ fontSrc }) { +function Quote({ stylesheet }) { const quote = use(fetchQuote()); return ( <> - +

{quote}

); } export default function App() { - const [fontSrc, setFontSrc] = useState(null); + const [stylesheet, setStylesheet] = useState(null); return ( <> - {fontSrc && ( + {stylesheet && ( ⌛ Loading quote...

}> - +
)} @@ -2967,7 +2974,7 @@ export default function VanillaQuote() { const style = document.createElement('style'); style.textContent = `@font-face { font-family: 'VanillaFancy'; - src: url(${freshFontUrl()}) format('truetype'); + src: url(${freshFontUrl()}) format('woff2'); font-display: swap; }`; document.head.appendChild(style); @@ -2983,11 +2990,19 @@ export default function VanillaQuote() { ``` ```js src/font.js hidden -// Add a unique parameter so the font isn't cached, -// and every run shows the loading state. +export function freshStylesheetUrl() { + // Add a unique parameter so the stylesheet isn't cached. + return ( + 'https://fonts.googleapis.com/css2?family=Caveat&display=swap' + + '&t=' + + Date.now() + ); +} + export function freshFontUrl() { + // Add a unique parameter so the font isn't cached. return ( - 'https://raw.githubusercontent.com/google/fonts/main/ofl/caveat/Caveat%5Bwght%5D.ttf' + + 'https://fonts.gstatic.com/s/caveat/v23/WnznHAc5bAfYB2QRah7pcpNvOx-pjfJ9eIWpYT5Kmgq3sw.woff2' + '?t=' + Date.now() ); @@ -3025,7 +3040,7 @@ export function fetchQuote() { margin-top: 1em; } .fancy { - font-family: 'Fancy', sans-serif; + font-family: 'Caveat', sans-serif; } .vanilla-fancy { font-family: 'VanillaFancy', sans-serif; @@ -3038,8 +3053,8 @@ hr { ```json package.json hidden { "dependencies": { - "react": "19.3.0-canary-f1f7ed2a-20260904", - "react-dom": "19.3.0-canary-f1f7ed2a-20260904", + "react": "19.3.0", + "react-dom": "19.3.0", "react-scripts": "latest" } } @@ -3051,20 +3066,23 @@ hr { ### Waiting for an image to load {/*waiting-for-an-image-to-load*/} -When a [``](/reference/react/ViewTransition) animates a Suspense boundary's reveal, React waits for visible images to load, up to a timeout, so the animation doesn't start with a half-loaded image. This only happens during a `` update. Adding an `onLoad` handler opts a specific image out, even inside a ``. - -In the example below, the Suspense boundary is wrapped in a `` and shows a profile skeleton until the portrait has loaded. +When a Suspense boundary reveals content inside a [``](/reference/react/ViewTransition), React waits up to 500 ms for visible images to load before starting the animation. An `onLoad` handler opts an image out. -For comparison, the second button performs the same update without React. Nothing waits for the image, so the card appears immediately and the image pops in when it loads: +Compare the same boundary inside and outside a ``. Inside, React keeps the profile skeleton visible for up to 500 ms while the image loads, so the card can be revealed with its image already in place. Outside, Suspense stops showing the skeleton as soon as the Promise resolves. If the image is still loading, the card appears first and the image pops in afterward: ```js -import { ViewTransition, Suspense, useState, startTransition } from 'react'; -import { freshImageUrl } from './image.js'; -import VanillaProfile from './VanillaProfile.js'; +import { + ViewTransition, + Suspense, + use, + useState, +} from 'react'; +import { fetchImageSrc } from './image.js'; -function Profile({ src }) { +function Profile({ cacheKey }) { + const src = use(fetchImageSrc(cacheKey)); return (
Jack Pope @@ -3083,56 +3101,51 @@ function ProfilePlaceholder() { } export default function App() { - const [src, setSrc] = useState(null); + const [showWithTransition, setShowWithTransition] = + useState(false); + const [showWithoutTransition, setShowWithoutTransition] = + useState(false); return ( <> - - {src && ( + {showWithTransition && ( }> - + )}
- + + {showWithoutTransition && ( + }> + + + )} ); } ``` -```js src/VanillaProfile.js -import { useRef } from 'react'; -import { freshImageUrl } from './image.js'; +```js src/image.js hidden +// Normally, the caching logic would be inside a framework. +const cache = new Map(); -export default function VanillaProfile() { - const ref = useRef(null); - function show() { - ref.current.innerHTML = `
- Jack Pope -

Jack Pope

-
`; +export function fetchImageSrc(cacheKey) { + if (!cache.has(cacheKey)) { + cache.set(cacheKey, loadImageSrc()); } - return ( - <> - -
- - ); + return cache.get(cacheKey); } -``` -```js src/image.js hidden -// Add a unique parameter so the image isn't cached, -// and every run shows the loading state. -export function freshImageUrl() { +async function loadImageSrc() { + // Delay the response so the Suspense fallback is visible. + await new Promise(resolve => setTimeout(resolve, 1000)); + // Add a unique parameter so the image isn't cached. return 'https://react.dev/images/team/jack-pope.jpg?t=' + Date.now(); } ``` @@ -3171,8 +3184,8 @@ hr { ```json package.json hidden { "dependencies": { - "react": "19.3.0-canary-f1f7ed2a-20260904", - "react-dom": "19.3.0-canary-f1f7ed2a-20260904", + "react": "19.3.0", + "react-dom": "19.3.0", "react-scripts": "latest" } } @@ -3262,7 +3275,6 @@ import { freshStylesheetUrl, freshImageUrl } from './resources.js'; export default function VanillaProfileCard() { const ref = useRef(null); async function show() { - const quote = await fetchQuote(); const doc = ref.current.contentWindow.document; doc.open(); doc.write(` @@ -3272,17 +3284,34 @@ export default function VanillaProfileCard() { .profile-card img { border-radius: 50%; background: #dfe3e9; } .name { margin: 0 0 4px; font-family: 'Caveat', sans-serif; font-size: 22px; line-height: 28px; font-weight: bold; } .bio { margin: 0; font-family: 'Caveat', sans-serif; font-size: 20px; line-height: 26px; } + .avatar-placeholder { width: 80px; height: 80px; border-radius: 50%; background: #dfe3e9; } + .name-placeholder, .bio-placeholder { border-radius: 4px; background: #dfe3e9; color: transparent; } + .name-placeholder { width: 90px; } + .bio-placeholder { width: 220px; height: 52px; }
- Jack Pope +
-

Jack Pope

-

${quote}

+

 

+

 

- `); doc.close(); + + const quote = await fetchQuote(); + doc.body.innerHTML = ` +
+ Jack Pope +
+

Jack Pope

+

${quote}

+
+
`; + const stylesheet = doc.createElement('link'); + stylesheet.rel = 'stylesheet'; + stylesheet.href = freshStylesheetUrl(); + doc.head.appendChild(stylesheet); } return ( <> @@ -3375,6 +3404,7 @@ hr { } .bio-placeholder { width: 220px; + height: 52px; } .vanilla-frame { display: block; @@ -3388,8 +3418,8 @@ hr { ```json package.json hidden { "dependencies": { - "react": "19.3.0-canary-f1f7ed2a-20260904", - "react-dom": "19.3.0-canary-f1f7ed2a-20260904", + "react": "19.3.0", + "react-dom": "19.3.0", "react-scripts": "latest" } } diff --git a/src/content/reference/react/ViewTransition.md b/src/content/reference/react/ViewTransition.md index 1011a1c9623..17595bb763e 100644 --- a/src/content/reference/react/ViewTransition.md +++ b/src/content/reference/react/ViewTransition.md @@ -1253,9 +1253,9 @@ It's important to properly use keys to preserve identity when reordering lists. ### Animating from Suspense content {/*animating-from-suspense-content*/} -Like any Transition, React waits for data and new CSS (``) before running the animation. In addition to this, ViewTransitions also wait up to 500ms for new fonts to load before starting the animation to avoid them flickering in later. For the same reason, an image wrapped in ViewTransition will wait for the image to load. See examples of [waiting for a font](/reference/react/Suspense#waiting-for-a-font-to-load) and [waiting for an image](/reference/react/Suspense#waiting-for-an-image-to-load) on the Suspense page. +Like any Transition, React waits for data and new CSS (``) before running the animation. `` also waits up to 500 ms for new fonts and visible images to load so they don't flicker in after the animation starts. See examples of [waiting for a font](/reference/react/Suspense#waiting-for-a-font-to-load) and [waiting for an image](/reference/react/Suspense#waiting-for-an-image-to-load) on the Suspense page. -If it's inside a new Suspense boundary instance, then the fallback is shown first. After the Suspense boundary fully loads, it triggers the `` to animate the reveal to the content. +If the content is inside a new Suspense boundary, React first shows the fallback. After the boundary finishes loading, `` animates the reveal to the content. There are two ways to animate Suspense boundaries depending on where you place the ``: @@ -1315,7 +1315,7 @@ export function VideoPlaceholder() { ``` ```js -import {ViewTransition, useState, startTransition, Suspense} from 'react'; +import { ViewTransition, Suspense, useState } from 'react'; import {Video, VideoPlaceholder} from './Video'; import {useLazyVideoData} from './data'; @@ -1328,12 +1328,7 @@ export default function Component() { const [showItem, setShowItem] = useState(false); return ( <> - {showItem ? ( @@ -1486,8 +1481,8 @@ button:hover { ```json package.json hidden { "dependencies": { - "react": "19.3.0-canary-f1f7ed2a-20260904", - "react-dom": "19.3.0-canary-f1f7ed2a-20260904", + "react": "19.3.0", + "react-dom": "19.3.0", "react-scripts": "latest" } }