diff --git a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx index 0fa1e2d11a5b..c306794c0b8f 100644 --- a/docs/01-app/01-getting-started/04-linking-and-navigating.mdx +++ b/docs/01-app/01-getting-started/04-linking-and-navigating.mdx @@ -3,6 +3,7 @@ title: Linking and Navigating description: Learn how the built-in navigation optimizations work, including prefetching, prerendering, and client-side navigation, and how to optimize navigation for dynamic routes and slow networks. related: links: + - app/guides/loading-ui - app/api-reference/components/link - app/api-reference/file-conventions/loading - app/guides/prefetching diff --git a/docs/01-app/01-getting-started/06-fetching-data.mdx b/docs/01-app/01-getting-started/06-fetching-data.mdx index ffb549ab0405..8f664a19f3cc 100644 --- a/docs/01-app/01-getting-started/06-fetching-data.mdx +++ b/docs/01-app/01-getting-started/06-fetching-data.mdx @@ -5,6 +5,7 @@ related: title: API Reference description: Learn more about the features mentioned in this page by reading the API Reference. links: + - app/guides/loading-ui - app/guides/data-security - app/api-reference/functions/fetch - app/api-reference/file-conventions/loading diff --git a/docs/01-app/02-guides/instant-navigation.mdx b/docs/01-app/02-guides/instant-navigation.mdx index 6d866e2c4245..0e4387050ed2 100644 --- a/docs/01-app/02-guides/instant-navigation.mdx +++ b/docs/01-app/02-guides/instant-navigation.mdx @@ -6,6 +6,7 @@ related: title: Learn more description: Explore the full instant API, caching, and revalidation. links: + - app/guides/loading-ui - app/api-reference/file-conventions/route-segment-config/instant - app/guides/runtime-prefetching - app/getting-started/caching diff --git a/docs/01-app/02-guides/interactive-apps.mdx b/docs/01-app/02-guides/interactive-apps.mdx index 6f4a8966ea9e..dc765624153d 100644 --- a/docs/01-app/02-guides/interactive-apps.mdx +++ b/docs/01-app/02-guides/interactive-apps.mdx @@ -5,6 +5,7 @@ nav_title: Interactive apps related: description: Learn more about the APIs used in this guide. links: + - app/guides/loading-ui - app/api-reference/functions/refresh - app/api-reference/directives/use-cache - app/api-reference/functions/cacheTag @@ -15,7 +16,9 @@ When a user interaction requires server-side work, the result is not available i This guide adds responsive feedback to a task management app called _Taskboard_. Each step handles a different kind of pending work, from streaming slow reads to confirming mutations before the server responds. -> **Good to know:** The patterns in this guide also improve [Core Web Vitals](https://web.dev/articles/vitals). Streaming with [``](https://react.dev/reference/react/Suspense) lowers [FCP](https://web.dev/articles/fcp) and [LCP](https://web.dev/articles/lcp) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower [INP](https://web.dev/articles/inp) by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions. +For the loading reveals themselves, such as sizing skeletons, choosing Suspense boundary granularity, and animating the swap to real content, see [Designing loading UI](/docs/app/guides/loading-ui). + +> **Good to know:** The patterns in this guide also improve [Core Web Vitals](https://web.dev/articles/vitals). Streaming with [``](https://react.dev/reference/react/Suspense) lowers [FCP](https://web.dev/articles/fcp) and [LCP](https://web.dev/articles/lcp) by letting the shell paint while slow reads finish. Optimistic UI and transitions lower [INP](https://web.dev/articles/inp) by keeping the click frame fast. Prefetching the next route makes FCP, LCP, and INP on the destination page near-instant. These patterns help, but they don't replace the usual INP work: shipping less client JavaScript and avoiding blocking roundtrips during interactions. For [CLS](https://web.dev/articles/cls), keeping placeholders and streamed content from shifting the layout as it loads, see [Designing loading UI](/docs/app/guides/loading-ui). ## Example @@ -53,7 +56,7 @@ import { getTask } from '@/features/task/task-queries' import { TaskDetail } from '@/features/task/components/task-detail' import { CommentSection } from '@/features/task/components/comment-section' -export default async function TaskPage({ params }) { +export default async function TaskPage({ params }: PageProps<'/task/[id]'>) { const { id } = await params const task = await getTask(id) @@ -81,7 +84,7 @@ import { CommentSectionSkeleton, } from '@/features/task/components/comment-section' -export default function TaskPage({ params }) { +export default function TaskPage({ params }: PageProps<'/task/[id]'>) { return (
}> @@ -693,7 +696,7 @@ const nextConfig: NextConfig = { export default nextConfig ``` -Enabling Cache Components applies prerender validation across every route, not just the ones in this step. A route that reads request data such as `cookies()`, `headers()`, or `searchParams` outside [``](https://react.dev/reference/react/Suspense) now blocks prerendering and needs the Step 1 treatment. To turn it on in an existing app, see [Migrating to Cache Components](/docs/app/guides/migrating-to-cache-components). +Enabling Cache Components applies prerender validation across every route, not only the ones in this step. A route that reads request data such as `cookies()`, `headers()`, or `searchParams` outside [``](https://react.dev/reference/react/Suspense) now blocks prerendering and needs the Step 1 treatment. To turn it on in an existing app, see [Migrating to Cache Components](/docs/app/guides/migrating-to-cache-components). #### Cache reusable reads @@ -743,7 +746,7 @@ If the destination's cached reads depend on request data (cookies, headers, `sea ```tsx filename="app/task/[id]/page.tsx" export const prefetch = 'allow-runtime' -export default function TaskPage({ params }) { +export default function TaskPage({ params }: PageProps<'/task/[id]'>) { // … } ``` @@ -770,10 +773,11 @@ The patterns in this guide combine a handful of primitives: | Reusable reads should survive across requests and stay fresh after writes | [`'use cache'`](/docs/app/api-reference/directives/use-cache) with [`cacheTag`](/docs/app/api-reference/functions/cacheTag), revalidated by [`updateTag`](/docs/app/api-reference/functions/updateTag) or [`revalidateTag`](/docs/app/api-reference/functions/revalidateTag) | | Navigation between pages of an interactive app should feel instant | [``](/docs/app/api-reference/components/link) prefetching with [Partial Prefetching](/docs/app/guides/adopting-partial-prefetching), and [Runtime prefetching](/docs/app/guides/runtime-prefetching) for request-dependent reads | -Most patterns mix two or more of these. Reach for whichever primitive fits the constraint you're solving. +Most patterns mix two or more of these. Reach for whichever one fits the constraint you're solving. Related guides: - [Streaming](/docs/app/guides/streaming) for loading boundaries and `` patterns - [Instant Navigation](/docs/app/guides/instant-navigation) for validating that navigations stay instant +- [Designing loading UI](/docs/app/guides/loading-ui) for sizing fallbacks and ordering their reveals - [View Transitions](/docs/app/guides/view-transitions) for animating state changes diff --git a/docs/01-app/02-guides/loading-ui.mdx b/docs/01-app/02-guides/loading-ui.mdx new file mode 100644 index 000000000000..2d96df2cfecd --- /dev/null +++ b/docs/01-app/02-guides/loading-ui.mdx @@ -0,0 +1,542 @@ +--- +title: Designing loading UI +description: Fix the loading UI problems that show up most often in real apps, including popcorn loading, competing spinners, false empty states, skeletons that shift the layout, and pending indicators that flash on fast responses. +nav_title: Loading UI +related: + description: Learn more with these related guides. + links: + - app/guides/streaming + - app/guides/instant-navigation + - app/guides/view-transitions + - app/guides/interactive-apps + - app/guides/profiling-performance +--- + +Loading UI goes wrong in a small number of recognizable ways. A route-level `loading.tsx` ends up competing with a page skeleton and a per-query spinner, or one coarse boundary hides a header that could have painted immediately. Sections reveal in whatever order their data returns and push the ones below them down, and a skeleton that reserves the wrong height makes the page jump when the real rows arrive. + +This guide fixes each of those problems in one app. The sections are independent, so read it start to finish or jump to the problem you have. + +## Example + +As an example, we'll fix the loading UI of a team directory that lists members, searches them, paginates them, and links to each member's profile. + +You can find the resources used in this example here: + +- [Demo](https://loading-ui-demo.labs.vercel.dev) +- [Code](https://github.com/vercel-labs/loading-ui-demo) + +> **Good to know**: The demo runs with [Cache Components](/docs/app/guides/migrating-to-cache-components) and [Partial Prefetching](/docs/app/guides/adopting-partial-prefetching) enabled, which is why the shell appears instantly on navigation. The fixes below don't depend on either. + +## Getting the shell to paint + +### Top-level awaits that block the shell + +Awaiting anything at the top of a page component blocks the entire route. Nothing renders, including the parts that never needed data, so the user gets a blank screen for as long as the slowest await takes. + +The awaits are usually `params`, `searchParams`, a query, an authorization check, or a [`notFound()`](/docs/app/api-reference/functions/not-found) decision: + +```tsx filename="app/member/[id]/page.tsx" +// Blocks the shell. The back link can't paint until the member query resolves. +export default async function MemberPage({ + params, +}: PageProps<'/member/[id]'>) { + const { id } = await params + const member = await getMember(Number(id)) + if (!member) notFound() + + return ( +
+ Directory + +
+ ) +} +``` + +Keep the page component synchronous and push each read down into the component that needs it. The page becomes a layout of static markup and boundaries, which is exactly what can be prerendered: + +```tsx filename="app/member/[id]/page.tsx" +export default function MemberPage({ params }: PageProps<'/member/[id]'>) { + return ( +
+ {/* Static, so it goes out with the first HTML */} + Directory + + }> + {params.then(({ id }) => ( + + ))} + +
+ ) +} +``` + +The `notFound()` check moves inside `ProfileHeader`, where it runs against data that component already has. A 404 decision at the page level gates every byte of the response on a query that only one section needs. + +We can confirm the result with [`next build`](/docs/app/api-reference/cli/next#next-build-options). The route is now prerendered up to its boundaries: + +```bash filename="Terminal" +Route (app) +┌ ◐ / +└ ◐ /member/[id] + +◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content +``` + +With an `await` back at the top of the page, the same route reports no static content, because there is nothing the framework can generate ahead of the request. + +> **Good to know**: With [Cache Components](/docs/app/getting-started/caching), awaiting uncached data outside a boundary is a [build-time error](/docs/messages/blocking-prerender-dynamic) rather than a silent performance cliff, because the route can no longer prerender a shell. + +### Stable UI hidden behind one coarse boundary + +A boundary hides everything inside it until its slowest child is ready, so anything inside one that could have rendered immediately waits for no reason. Headers, titles, navigation, search controls, and empty containers all belong outside. + +Put the boundary around the part that's actually slow: + +```tsx filename="app/page.tsx" +import { Suspense } from 'react' +import { Stats } from './stats' +import { MemberList, DirectorySkeleton } from './member-list' + +export default function Page({ searchParams }: PageProps<'/'>) { + return ( +
+
+

Team directory

+ {/* Synchronous, so it never sits behind a fallback */} + +
+ + }> + {searchParams.then(({ q, page }) => ( + + ))} + +
+ ) +} +``` + +The header and the skeleton go out with the first HTML, and the list replaces the skeleton when its query resolves. If `` sat inside the boundary, it would wait behind the skeleton too. + +Controls belong outside too. A search input that only writes the query to the URL stays in the shell and is usable on the first paint, while one that also reads the query back becomes request-dependent and needs a placeholder of its own. Seeding its initial value is then a separate concern, covered in [Preventing flash before hydration](/docs/app/guides/preventing-flash-before-hydration). + +When `Stats` has to await something, even 250ms, it can't render in the shell, and it also shouldn't join the list's boundary and wait behind the slow query. Give it its own boundary with a placeholder that reserves its height: + +```tsx filename="app/page.tsx" +
+

Team directory

+ + }> + + +
+``` + +The placeholder only has to hold the space the real stats will occupy, which for a single line of text means matching its line height. The header still paints with the first HTML, and the two reads resolve on their own schedules instead of the faster one inheriting the slower one's wait. + +The page's largest element belongs outside as well. A skeleton is not a [Largest Contentful Paint](/docs/app/guides/streaming#lcp-largest-contentful-paint) candidate, because LCP only counts images, video posters, background images, and blocks that contain text. A placeholder built from empty sized boxes never registers as one, so the LCP timestamp waits for the real content and streaming a hero image or a headline delays it by that query's full duration. Keep the largest above-the-fold element in the [static shell](/docs/app/guides/streaming#the-static-shell), and if it's an image, give it [`priority`](/docs/app/api-reference/components/image#priority) so it isn't lazy-loaded. + +### Competing loading signals + +One region should produce one loading signal. Apps accumulate them by adding each in isolation, and the user ends up watching a route-level `loading.tsx`, then a page-level skeleton, then a spinner inside a card, for a single page load. + +[`loading.tsx`](/docs/app/api-reference/file-conventions/loading) is one boundary around the whole route, so it fits a page whose entire first render waits on data. Once any part of the page can paint sooner, `` around the slow part replaces it rather than joining it. A full-page skeleton also has to be maintained alongside the real page, and it drifts out of sync as the layout changes. See [When to use `loading.js` vs ``](/docs/app/guides/streaming#when-to-use-loadingjs-vs-suspense). + +Layouts add signals the same way. A `loading.tsx` above a shared layout doesn't show on client navigations, because React re-enters the tree at that layout, so an overlay added to compensate ends up firing on the first load when the fallback already covers it. See [Ensuring instant navigations](/docs/app/guides/instant-navigation). + +A section that already shows a skeleton doesn't need a second spinner for a query inside it. A `` pointing at a destination that has its own shell doesn't need one either, because the destination's skeletons are already showing by the time the navigation commits. When the destination has no shell, [`useLinkStatus`](/docs/app/api-reference/functions/use-link-status#gracefully-handling-fast-navigation) gives the link its own pending state. + +### Data fetching split across client effects + +Fetching in a client effect puts every request behind the JavaScript bundle. The shell paints empty, the requests start after hydration, and each component that fetches for itself adds a round trip and its own `isLoading` spinner: + +```tsx filename="app/member-list.tsx" +'use client' + +// The request can't start until this component has downloaded and hydrated. +export function MemberList() { + const [members, setMembers] = useState(null) + + useEffect(() => { + fetch('/api/members') + .then((res) => res.json()) + .then(setMembers) + }, []) + + if (!members) return + return +} +``` + +Nesting components like this produces a waterfall of dependent requests and scatters the loading UI across whichever components happen to fetch. Reading the data in a Server Component behind one boundary starts the query on the server before the bundle has downloaded, and puts the loading state where you chose to put it. + +## Designing the fallback + +### Skeletons that don't match the final geometry + +A fallback that's smaller or larger than the content replacing it makes the browser reflow the page, and everything below it moves. That jump is [Cumulative Layout Shift](/docs/app/guides/streaming#cls-cumulative-layout-shift). The usual causes are a wrong row count, a wrong row height, a button or input narrower than the real one, missing icons, and a spinner standing in for a card. + +Define the row box once and use it on both sides, so the two can't drift. What carries the definition doesn't matter, only that there is one of it: class-name constants, a CSS module class applied to both, or a single rule in a stylesheet all work, while two rules that merely look alike are what drifts. Give the container a minimum height as well, and the full space is reserved before any data arrives: + +```tsx filename="app/member-list.tsx" +const LIST_BOX = 'mt-4 min-h-[512px] divide-y divide-border' +const ROW_BOX = 'h-16' +const ROW_INNER = 'flex h-full items-center gap-3.5 px-4' + +export function MemberRowsSkeleton({ rows = PAGE_SIZE }: { rows?: number }) { + return ( + <> + {Array.from({ length: rows }).map((_, i) => ( +
  • +
    + + + + + +
    +
  • + ))} + + ) +} +``` + +Put the height on the same element in both, and give the placeholder the same DOM shape as the real row. Moving the height inward is enough to break it. With `h-16` on the row's inner `` instead of its `
  • `, the real row comes out one pixel taller than its skeleton, because the `
  • ` adds the list divider on top of a full-height child. One pixel per row is eight pixels of shift on a page of eight rows. Reload with the layout shift regions overlay enabled in your browser's dev tools and the list reports a shift on every load. + +The placeholder is decorative, so it's `aria-hidden` and carries no text for a screen reader to announce. Keeping it in the same file as the component, built from the same box constants, is what keeps the two matched as the component changes. + +Space outside the fallback shifts the page the same way, and a correct skeleton won't save a layout that leaves it unreserved. An image, video, or iframe without `width` and `height` or an `aspect-ratio` collapses until its file loads, which is why [`next/image`](/docs/app/api-reference/components/image) requires dimensions and why [`placeholder="blur"`](/docs/app/api-reference/components/image#placeholder) fills the box beforehand. A web font whose metrics differ from its fallback reflows every line of text when it swaps in, which [`next/font`](/docs/app/api-reference/components/font) handles through [`adjustFontFallback`](/docs/app/api-reference/components/font#adjustfontfallback), on by default. Anything inserted above existing content, such as a cookie banner or an alert bar, moves the whole page down after it looked settled, so reserve that space in the initial HTML or take the element out of flow. + +### False empty states + +Rendering a real state from data you don't have yet is worse than rendering a placeholder. "No members yet" while the query is in flight tells the user the team is empty, and the UI then flips to a full list a moment later. The same failure produces a default product type, a zero count, or a logged-out header that corrects itself. + +The distinction is between "resolved and empty" and "not resolved yet". Decide the empty state from a value that has actually resolved. A count is cheap next to fetching rows, so it can settle the question before any row arrives: + +```tsx filename="app/member-list.tsx" +export function MemberList({ + query, + pageCount, +}: { + query: string + pageCount: number +}) { + const total = countMembers(query) + + if (total === 0) { + return

    No members match “{query}”.

    + } + + return +} +``` + +A Server Component that awaits its rows instead gets the same guarantee for free, because the code after the `await` only runs once the data exists. The failure is specific to the client, where the pending state and the resolved value share one variable and `undefined` gets treated as empty. Check for pending before checking the value, and let the fallback cover it rather than rendering a plausible alternative. + +Pass the resolved count down. Each page's fallback uses it to reserve the number of rows that page will hold. + +### Fallbacks that never resolve + +A loading state promises that content is coming, so it has to end in either content or an error. A section whose query throws leaves a skeleton pulsing forever unless something catches it. + +Wrap a slow section in an [error boundary](/docs/app/getting-started/error-handling) at the same granularity as its `` boundary. A failure then shows an error in that section, and one failed section doesn't blank the rest of the page. See [error handling mid-stream](/docs/app/guides/streaming#error-handling-mid-stream) for how status codes behave once the stream has started. + +Users treat a placeholder that is still animating after about [10 seconds](https://www.nngroup.com/articles/response-times-3-important-limits/) as a failure. For an operation you know runs long, show determinate progress such as a percent-done bar, and let the user keep working while it finishes. + +## Ordering the reveal + +### Popcorn loading + +Popcorn loading is what happens when every section gets its own boundary. Each region appears the moment its own data arrives, in network order, and sections of unknown height push each other around as they land. + +A member profile stacks two sections whose height nobody can predict. The bio might be one paragraph or three, and the activity feed might be two rows or eight, so no skeleton can reserve the right height for either. As siblings, the feed often appears first, high on the page, and then moves down when the bio above it arrives. + +Nesting the feed's boundary inside the bio's fixes the order. A nested boundary can't reveal before its parent, so the sections appear top-down and a long bio grows into the space below it: + +```tsx filename="app/member/[id]/page.tsx" +import { Suspense } from 'react' +import { + BioText, + BioTextSkeleton, + ActivityRows, + ActivityRowsSkeleton, +} from './profile' + +export default function MemberPage({ params }: PageProps<'/member/[id]'>) { + return ( +
    + }> + {params.then(({ id }) => ( + <> + + }> + + + + ))} + +
    + ) +} +``` + +Both queries still run in parallel. Nesting delays the reveal of a finished section, not its query. + +Sibling boundaries are right when the regions are visually independent, such as dashboard cards in a grid, because each card can appear as soon as its data is ready and nothing below it has to move. One boundary around the whole stack is right when per-section reveals aren't worth the extra fallbacks. + +> **Good to know**: The [profiling guide](/docs/app/guides/profiling-performance#step-2-find-content-gated-by-one-boundary) shows these sections streaming in the Performance panel, so you can confirm the queries run in parallel and spot a boundary that gates too much content. + +### Stable labels that disappear and reappear + +A label the app already knows doesn't belong in the fallback. When a section heading is drawn as a skeleton bar and then rendered again as real text, the same word leaves the DOM and re-enters it a frame later, and any reveal animation on that region animates a label that never changed. + +Render the label as real text and let the fallback cover only what's actually unknown: + +```tsx filename="app/member/[id]/page.tsx" +About + +}> + {params.then(({ id }) => ( + + ))} + +``` + +Where the label goes depends on whether its position is settled. "About" sits under a profile header whose skeleton is the same size as the real header, so its position is fixed from the first paint and it belongs in the shell. A heading below a variable-height section is different. Its position isn't known until that section resolves, so putting it in the shell would only move it later. Render it inside the parent's boundary, still as real text, next to the fallback for its own rows: + +```tsx filename="app/member/[id]/page.tsx" +}> + {params.then(({ id }) => ( + <> + + + Recent activity + }> + + + + ))} + +``` + +Either way the label is never a skeleton bar. The rule is about what the fallback contains, not about how far up the tree the label goes. + +This applies to container chrome as well as headings. Cards, borders, table headers, and empty list containers can all render immediately, leaving the fallback responsible for rows alone. + +### Content that pops in + +By default a fallback is replaced by real content in a single frame. Wrapping the streamed content in React's [``](https://react.dev/reference/react/ViewTransition) crossfades the two instead. View transitions work in the App Router with no configuration. + +Reuse one wrapper for every reveal. `enter="auto"` animates the first time content appears, and `default="none"` skips animating later updates of the same region: + +```tsx filename="app/crossfade.tsx" +import { ViewTransition } from 'react' + +export function Crossfade({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} +``` + +Wrap the fallback in a matching `exit="auto"` so the skeleton fades out as the content fades in: + +```tsx filename="app/member/[id]/page.tsx" + + + + } +> + {params.then(({ id }) => ( + + + + ))} +
    +``` + +Content that's already in the static shell has nothing to reveal, so don't animate it, and don't wrap a subtree that re-renders on every keystroke or the enter animation fires on every character. + +A reveal runs while the page is still streaming and hydrating, when the main thread is busiest. Animate only `opacity` and `transform`, which the compositor runs on its own thread. A shimmer that animates `background-position`, or a skeleton that animates `height`, forces paint or layout on every frame and competes with hydration for the same thread. Keep the placeholder animation quiet for the same reason. A slow opacity pulse marks the region as loading without competing with content that has already arrived beside it. + +> **Good to know**: Honor [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) for users who ask for less motion. See [Respecting reduced motion](/docs/app/guides/view-transitions#respecting-reduced-motion) for the CSS, and [Designing view transitions](/docs/app/guides/view-transitions) for directional navigation and shared-element morphs. + +## Handling updates + +### Updates that wipe the current results + +Reusing the first-load fallback when the user searches or filters replaces the results they are reading with a skeleton, and every keystroke does it again. + +Next.js wraps App Router navigations in a transition, and during a transition `` keeps content it has already revealed. The current rows stay on screen until the next result is ready, which leaves the pending signal to the control that started the update: + +```tsx filename="app/search-shell.tsx" +'use client' + +import { useTransition } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' + +export function SearchShell({ children }: { children: React.ReactNode }) { + const router = useRouter() + const searchParams = useSearchParams() + const [isPending, startTransition] = useTransition() + + function onSearch(value: string) { + startTransition(() => { + router.replace(value ? `/?q=${encodeURIComponent(value)}` : '/', { + scroll: false, + }) + }) + } + + return ( +
    + onSearch(e.target.value)} + aria-label="Search members" + /> + +
    + {children} +
    +
    + ) +} +``` + +The wrapper sets `data-pending`, which dims the stale rows until the next results replace them. Changing the `key` on the `` boundary would remount it and bring the first-load skeleton back, so leave the key alone. + +The input stays uncontrolled, which also protects [INP](/docs/app/guides/streaming#inp-interaction-to-next-paint). A controlled input that re-renders a large subtree on every keystroke delays the keypress itself, while an uncontrolled field paired with a transition keeps typing off that path. + +See [Building interactive apps](/docs/app/guides/interactive-apps) for the mutation side of this, including optimistic updates and form feedback. + +### Spinners that flash on fast operations + +An indicator bound straight to a pending flag appears the instant the update starts, even when the response arrives a few milliseconds later. The spinner appears and disappears within a few frames, on an operation that was already fast. + +Start the indicator invisible and reveal it only after a short delay, so a fast response resolves before it's ever seen: + +```css filename="app/globals.css" +/* Invisible for 150ms, so a fast search resolves before it is ever seen. */ +.pending { + opacity: 0; +} + +.pending[data-active] { + animation: + pending-in 0.2s cubic-bezier(0.23, 1, 0.32, 1) 0.15s forwards, + spin 0.7s linear infinite; +} + +@keyframes pending-in { + to { + opacity: 1; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} +``` + +A search that resolves inside the delay never shows the spinner. The exact figure is a design choice, and anything in the 100ms to 200ms range keeps genuinely fast responses silent without making a slow one feel unacknowledged. `` applies a window of its own before showing a fallback, so a fast stream doesn't flash a skeleton either, and [`useLinkStatus`](/docs/app/api-reference/functions/use-link-status#gracefully-handling-fast-navigation) has the same treatment written for a link's own pending hint. + +Hold the spinner for a minimum time once it is visible, because clearing `data-active` the moment the pending flag turns false still flickers when the response is only slightly slower than the delay. And when the surrounding UI already communicates the wait, such as rows dimming during a search, an indicator can be dropped entirely. + +### Pagination that remounts the list + +"Load more" should add rows, not replace them. Rekeying the list, or widening one boundary so it now covers page two as well, unmounts rows the user is already looking at, resets scroll position, and animates content that never changed. + +Give each page its own boundary and a stable key, so the rows already on screen keep their DOM nodes when the next page arrives: + +```tsx filename="app/member-list.tsx" +
      + {pageNumbers.map((page) => ( + // Stable key per page, so page 1 is untouched when page 2 arrives + }> + + + ))} +
    +``` + +Size each page's fallback to the rows that page will actually hold. A partial final page whose fallback reserves eight rows for four shifts the moment it resolves. + +Reserve the button's slot too. Whether a "Load more" is needed at all depends on data, so an unreserved control pushes the page down when it arrives, and again when the last page turns out not to need one. + +Put the pending state on the button. The rows above the button are deliberately left alone, so nothing else on screen changes to acknowledge the click. Swap the label and add an indicator beside it in a fixed-size control, so the box can't resize while it reports. + +> **Good to know**: Whether the incoming page's own fallback ever appears depends on how quickly the pages already on screen can re-render. React will not commit a new tree while a boundary that already has visible content is suspended, so if loading page two also re-runs page one's query, nothing paints until both are done. Serving the earlier pages from a [cache](/docs/app/getting-started/caching) is what makes that window exist. + +## Verifying the sequence + +Fallbacks are hard to review because a good one flashes by. The [React Developer Tools](https://react.dev/learn/react-developer-tools) let you suspend a selected `` boundary so its fallback stays on screen while you check its sizing, contrast, and dark mode. The Next.js DevTools freeze the initial and prefetched UI for a route, which shows exactly which parts are fallback. See [Visualize loading states with the Next.js DevTools](/docs/app/guides/instant-navigation#visualize-loading-states-with-the-nextjs-devtools). + +Then throttle the network and CPU. Fallbacks that never appear on a local machine appear on a mid-range phone, and that's the sequence most of your users get. + +Measure layout stability rather than judging it by eye. A `layout-shift` [PerformanceObserver](https://developer.mozilla.org/en-US/docs/Web/API/LayoutShift) reports each shift with the nodes that moved and the distance they moved, which turns "something jumps somewhere" into a specific element and a number: + +```ts filename="e2e/cls.ts" +new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.hadRecentInput) continue + console.log( + entry.value, + entry.sources.map((s) => s.node) + ) + } +}).observe({ type: 'layout-shift', buffered: true }) +``` + +Run it across the journeys the page supports, not only the first load. A one pixel row mismatch and a placeholder that collapses to zero height look fine in a screenshot, and both are reported here. + +Tests usually cover the final loaded page and skip the frame that these problems live in. The `@next/playwright` [`instant()`](/docs/app/guides/instant-navigation#prevent-regressions-with-e2e-tests) helper asserts on the UI that's visible immediately, before dynamic content streams in, so a boundary that drifts to the wrong place fails the test: + +```typescript filename="e2e/loading.test.ts" +import { test, expect } from '@playwright/test' +import { instant } from '@next/playwright' + +test('the directory shell is instant', async ({ page }) => { + await page.goto('/') + + await instant(page, async () => { + // Stable UI is in the shell + await expect(page.locator('h1')).toContainText('Team directory') + // The fallback reserves the space the rows will occupy + const skeleton = page.locator('ul[aria-hidden]') + await expect(skeleton).toBeVisible() + expect((await skeleton.boundingBox())?.height).toBeGreaterThanOrEqual(512) + }) + + await expect(page.getByRole('listitem').first()).toBeVisible() +}) +``` + +A marker proves the right content is in the shell, and a dimension proves the fallback reserves the space, which is the half that regresses silently when a skeleton and its component drift apart. + +## Next steps + +You now know how to keep a shell painting, size a fallback so nothing moves, order the reveals top-down, and report an update without discarding what the user is reading. + +Next, learn how to: + +- [Stream a page](/docs/app/guides/streaming) and see how the HTML arrives. +- [Ensure instant navigations](/docs/app/guides/instant-navigation) so the shell is already on screen when the user clicks. +- [Build interactive apps](/docs/app/guides/interactive-apps) with transitions, optimistic UI, and form feedback. +- [Profile performance](/docs/app/guides/profiling-performance) to find a boundary that gates too much content. diff --git a/docs/01-app/02-guides/production-checklist.mdx b/docs/01-app/02-guides/production-checklist.mdx index e9731be720c3..6b1f2ec35a2d 100644 --- a/docs/01-app/02-guides/production-checklist.mdx +++ b/docs/01-app/02-guides/production-checklist.mdx @@ -63,7 +63,7 @@ While building your application, we recommend using the following features to en - **[Server Components](/docs/app/getting-started/fetching-data):** Leverage the benefits of fetching data on the server using Server Components. - **[Route Handlers](/docs/app/api-reference/file-conventions/route):** Use Route Handlers to access your backend resources from Client Components. But do not call Route Handlers from Server Components to avoid an additional server request. -- **[Streaming](/docs/app/api-reference/file-conventions/loading):** Use Loading UI and React Suspense to progressively send UI from the server to the client, and prevent the whole route from blocking while data is being fetched. +- **[Streaming](/docs/app/api-reference/file-conventions/loading):** Use Loading UI and React Suspense to progressively send UI from the server to the client, and prevent the whole route from blocking while data is being fetched. See [Designing loading UI](/docs/app/guides/loading-ui) for sizing fallbacks and ordering reveals so the streamed page does not shift or flash. - **[Parallel Data Fetching](/docs/app/getting-started/fetching-data#parallel-data-fetching):** Reduce network waterfalls by fetching data in parallel, where appropriate. - **[Data Caching](/docs/app/getting-started/caching):** Verify whether your data requests are being cached or not, and opt into caching, where appropriate. Ensure requests that don't use `fetch` are [cached](/docs/app/api-reference/functions/unstable_cache). - **[Static Images](/docs/app/api-reference/file-conventions/public-folder):** Use the `public` directory to automatically cache your application's static assets, e.g. images. diff --git a/docs/01-app/02-guides/profiling-performance.mdx b/docs/01-app/02-guides/profiling-performance.mdx index 5501743900ef..7c70a659b5ef 100644 --- a/docs/01-app/02-guides/profiling-performance.mdx +++ b/docs/01-app/02-guides/profiling-performance.mdx @@ -7,6 +7,7 @@ related: links: - app/getting-started/fetching-data - app/guides/streaming + - app/guides/loading-ui - app/guides/interactive-apps --- @@ -150,7 +151,7 @@ Record again. The waterfall is gone: the reads now start together instead of one Notice that after the fix the track shows `getUser` then `getTeams`, with no `getNotifications` span even though it still ran. In this app `getNotifications` finishes in 250ms while `getTeams` takes 300ms, so `getTeams` is the one holding up the render, and the span takes its name. -Often the better structure is to move each read into the component that uses it, so the read starts when that component renders instead of blocking the page up front. +Often the better structure is to move each read into the component that uses it, so the read starts when that component renders instead of blocking the page up front. The demo does this for its content sections: `ActivityFeed` awaits its own data, so it can sit behind its own boundary and stream independently. @@ -283,7 +284,7 @@ The same reasoning covers the other reads: cache the ones that change rarely, an ```tsx filename="app/dashboard/page.tsx" {/* cached: in the static shell, no fallback */} - + }> {/* live: streams per request */} diff --git a/docs/01-app/02-guides/public-static-pages.mdx b/docs/01-app/02-guides/public-static-pages.mdx index 246c903df897..8c1281eaf7de 100644 --- a/docs/01-app/02-guides/public-static-pages.mdx +++ b/docs/01-app/02-guides/public-static-pages.mdx @@ -225,6 +225,8 @@ export default async function Page() { The fallback is prerendered alongside the rest of our static and cached content. The inner component streams in later, once its async work completes. +Size the fallback to match the content that replaces it, or the page shifts when the promotion arrives. See [Designing loading UI](/docs/app/guides/loading-ui) for fallback geometry and reveal order. + With this change, Next.js can separate prerenderable work from request-time work and the route becomes [partially prerendered](/docs/app/glossary#partial-prerendering-ppr). Again, we can confirm this by running `next build`: diff --git a/docs/01-app/02-guides/streaming.mdx b/docs/01-app/02-guides/streaming.mdx index 3eaa930bc761..f6bcfb34ffa9 100644 --- a/docs/01-app/02-guides/streaming.mdx +++ b/docs/01-app/02-guides/streaming.mdx @@ -5,6 +5,7 @@ description: Learn how streaming works in Next.js and how to use it to progressi related: description: Related API references and guides. links: + - app/guides/loading-ui - app/api-reference/file-conventions/loading - app/getting-started/fetching-data - app/getting-started/linking-and-navigating diff --git a/docs/01-app/02-guides/view-transitions.mdx b/docs/01-app/02-guides/view-transitions.mdx index 300bc447f4ce..958d9839438d 100644 --- a/docs/01-app/02-guides/view-transitions.mdx +++ b/docs/01-app/02-guides/view-transitions.mdx @@ -2,6 +2,9 @@ title: Designing view transitions description: Learn how to use view transitions to communicate meaning during navigation, loading, and content changes in a Next.js app. nav_title: View transitions +related: + links: + - app/guides/loading-ui --- In web apps, route changes replace the entire page at once. One set of elements disappears, another appears, with no visual connection between them. A user selects a photo thumbnail to view it in detail on another page. They are the same image, but nothing on screen communicates that. diff --git a/docs/01-app/03-api-reference/03-file-conventions/layout.mdx b/docs/01-app/03-api-reference/03-file-conventions/layout.mdx index 80160934fbef..bb9267b89df4 100644 --- a/docs/01-app/03-api-reference/03-file-conventions/layout.mdx +++ b/docs/01-app/03-api-reference/03-file-conventions/layout.mdx @@ -327,6 +327,8 @@ In both cases, to ensure instant navigation, either: - Wrap runtime data access in your layout in its own `` boundary with a fallback. - Move uncached data fetching from `layout.js` into `page.js` where `loading.js` can show a fallback. +A layout-level fallback shows on the first load but not on client navigations, because React re-enters the tree at the layout. See [Designing loading UI](/docs/app/guides/loading-ui#competing-loading-signals) for what that means when a route also has a `loading.js` or its own page-level fallbacks. + ```tsx filename="app/dashboard/layout.tsx" switcher highlight={8-10} import { Suspense } from 'react' import { NavSkeleton } from './nav-skeleton' diff --git a/docs/01-app/03-api-reference/03-file-conventions/loading.mdx b/docs/01-app/03-api-reference/03-file-conventions/loading.mdx index 20b62e3d3457..7f560a2b4b08 100644 --- a/docs/01-app/03-api-reference/03-file-conventions/loading.mdx +++ b/docs/01-app/03-api-reference/03-file-conventions/loading.mdx @@ -1,6 +1,10 @@ --- title: loading.js description: API reference for the loading.js file. + +related: + links: + - app/guides/loading-ui --- The special file `loading.js` helps you create meaningful Loading UI with [React Suspense](https://react.dev/reference/react/Suspense). With this convention, you can show an [instant loading state](#instant-loading-states) from the server while the content of a route segment streams in. The new content is automatically swapped in once complete. @@ -49,6 +53,8 @@ Loading UI components do not accept any parameters. An instant loading state is fallback UI that is shown immediately upon navigation. You can prerender loading indicators such as skeletons and spinners, or a small but meaningful part of future screens such as a cover photo, title, etc. This helps users understand the app is responding and provides a better user experience. +A fallback that doesn't match the size of the content replacing it shifts the page when it resolves. See [Skeletons that don't match the final geometry](/docs/app/guides/loading-ui#skeletons-that-dont-match-the-final-geometry) for sizing one. + Create a loading state by adding a `loading.js` file inside a folder. ` boundaries, so the click lands on that shell and its fallbacks are already the feedback. Adding a hint on the link gives the user two signals for one navigation. See [Competing loading signals](/docs/app/guides/loading-ui#competing-loading-signals). Navigation is typically fast. Use `useLinkStatus` as a quick patch when you identify a slow transition, then iterate to fix the root cause with prefetching or a `loading.js` fallback. @@ -198,6 +200,8 @@ export default function Layout({ children }) { If the navigation to a new route is fast, users may see an unnecessary flash of the hint. One way to improve the user experience and only show the hint when the navigation takes time to complete is to add an initial animation delay (e.g. 100ms) and start the animation as invisible (e.g. `opacity: 0`). +This applies to any pending indicator, not only a link hint. See [Spinners that flash on fast operations](/docs/app/guides/loading-ui#spinners-that-flash-on-fast-operations) for the general treatment, including holding the indicator once it becomes visible. + ```css filename="app/styles/global.css" .link-hint { display: inline-block; diff --git a/evals/evals/agent-052-design-loading-ui/EVAL.ts b/evals/evals/agent-052-design-loading-ui/EVAL.ts new file mode 100644 index 000000000000..d58d90cfeba0 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/EVAL.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest' +import { environment } from '@vercel/agent-eval/eval' + +test('makes the first load stable and honest', async () => { + await expect(environment).toSatisfyCriterion(` + The final app must preserve the delayed stats and eight-member directory. + First load should present one understandable loading hierarchy, retain stable + shell content, and never present missing data as a plausible empty result. + Any placeholder should reserve approximately the final row/avatar and one-line + stats geometry. The independent stats read must not unnecessarily hold back + the directory. Accept any implementation that prevents these failures; do not + require a particular React or Next.js API. Inspect source and rendered HTML as + well as browser snapshots because generic ul/li content may be absent from an + accessibility snapshot. + `) +}) + +test('keeps search results useful and current', async () => { + await expect(environment).toSatisfyCriterion(` + The delayed member search must still work. While a replacement search is + pending, useful existing results should remain visible instead of being + replaced by a spinner or believable empty state. Rapid input must not allow an + older response to overwrite results for the current query, and fast work + should not cause a distracting spinner flash. Accept any implementation that + achieves those outcomes; do not require transitions, deferred values, request + cancellation, or any other specific API. + `) +}) + +test('grows the directory without resetting it', async () => { + await expect(environment).toSatisfyCriterion(` + Pagination, all eight members, and its artificial delay must remain. Choosing + Load more should keep the existing member rows mounted and visible while the + next members load, then extend the directory rather than replace or remount it. + Pending feedback may be local to the control or appended region. Accept any + implementation that preserves the stable list and prevents a full-list reset. + `) +}) + +test('coordinates related profile content', async () => { + await expect(environment).toSatisfyCriterion(` + Member profiles must preserve the delayed bio, activity, and related-people + reads, as well as the missing-member behavior. A profile should reveal those + related sections as a deliberate composition rather than a popcorn sequence + of competing generic spinners, without unnecessarily hiding content that can + already remain stable. Accept shared or nested fallbacks, coordinated data, + or another implementation that achieves the user-visible result; do not + require a particular boundary structure or data-fetching API. + `) +}) diff --git a/evals/evals/agent-052-design-loading-ui/PROMPT.md b/evals/evals/agent-052-design-loading-ui/PROMPT.md new file mode 100644 index 000000000000..6389f6ef9846 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/PROMPT.md @@ -0,0 +1 @@ +This team directory's loading UI feels broken. First load stacks several loading signals, briefly claims there are no members, and shifts when the real rows arrive. Search blanks useful results and can show an older response, while Load more replaces the list with a spinner. Member profiles reveal three related sections as a sequence of unrelated spinners. Make the experience stable and coherent without removing the stats, directory, search, pagination, profiles, missing-member behavior, or artificial latency. diff --git a/evals/evals/agent-052-design-loading-ui/app/DirectorySkeleton.tsx b/evals/evals/agent-052-design-loading-ui/app/DirectorySkeleton.tsx new file mode 100644 index 000000000000..0c78b636a28a --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/DirectorySkeleton.tsx @@ -0,0 +1,12 @@ +export function DirectorySkeleton() { + return ( +
      + {Array.from({ length: 3 }).map((_, i) => ( +
    • + + +
    • + ))} +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/MemberList.tsx b/evals/evals/agent-052-design-loading-ui/app/MemberList.tsx new file mode 100644 index 000000000000..b5a0bd525488 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/MemberList.tsx @@ -0,0 +1,75 @@ +'use client' + +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { getMembers, type Member } from './members' + +export function MemberList() { + const [members, setMembers] = useState([]) + const [query, setQuery] = useState('') + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + getMembers().then((result) => { + setMembers(result.members) + setHasMore(result.hasMore) + setIsLoading(false) + }) + }, []) + + function search(nextQuery: string) { + setQuery(nextQuery) + setPage(1) + setIsLoading(true) + setMembers([]) + getMembers(1, nextQuery).then((result) => { + setMembers(result.members) + setHasMore(result.hasMore) + setIsLoading(false) + }) + } + + function loadMore() { + const nextPage = page + 1 + setIsLoading(true) + getMembers(nextPage, query).then((result) => { + setMembers(result.members) + setPage(nextPage) + setHasMore(result.hasMore) + setIsLoading(false) + }) + } + + if (members.length === 0) return

    No members yet

    + + if (isLoading) return
    + + return ( +
    + search(event.target.value)} + /> +
      + {members.map((member) => ( +
    • + + + {member.name} + {member.role} + +
    • + ))} +
    + {hasMore ? ( + + ) : null} +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/Spinner.tsx b/evals/evals/agent-052-design-loading-ui/app/Spinner.tsx new file mode 100644 index 000000000000..926f0da59d01 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/Spinner.tsx @@ -0,0 +1,16 @@ +export function Spinner({ label }: { label?: string }) { + return ( +
    +
    + {label ?

    {label}

    : null} +
    + ) +} + +export function LayoutSpinner() { + return ( +
    + +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/globals.css b/evals/evals/agent-052-design-loading-ui/app/globals.css new file mode 100644 index 000000000000..3beb4625ba39 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/globals.css @@ -0,0 +1,114 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #fafafa; + color: #111; +} + +main { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1.25rem; +} + +.stats { + color: #666; +} + +input, +button { + font: inherit; +} + +input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ccc; + border-radius: 0.5rem; +} + +button { + margin-top: 1rem; + padding: 0.6rem 0.9rem; +} + +.member-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + background: white; +} + +.member-row { + display: flex; + align-items: center; + gap: 14px; + height: 64px; + padding: 0 16px; + border-bottom: 1px solid #eee; +} + +.member-row small { + display: block; + color: #666; +} + +.member-row a { + color: inherit; + font-weight: 650; +} + +.avatar { + width: 36px; + height: 36px; + flex: none; + border-radius: 50%; + background: #ddd; +} + +.member-row-short { + height: 32px; + padding: 4px 8px; +} + +.skeleton-bar { + display: block; + height: 8px; + margin: 4px 0; + border-radius: 4px; + background: #e5e5e5; +} + +.layout-spinner { + position: fixed; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + background: rgb(255 255 255 / 55%); +} + +.route-loading { + min-height: 60vh; + display: grid; + place-items: center; +} + +.spinner { + width: 28px; + height: 28px; + border: 3px solid #ddd; + border-top-color: #111; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/evals/evals/agent-052-design-loading-ui/app/layout.tsx b/evals/evals/agent-052-design-loading-ui/app/layout.tsx new file mode 100644 index 000000000000..f0106bf310b1 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next' +import './globals.css' +import { LayoutSpinner } from './Spinner' + +export const metadata: Metadata = { + title: 'Team directory', + description: 'Company team directory', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/loading.tsx b/evals/evals/agent-052-design-loading-ui/app/loading.tsx new file mode 100644 index 000000000000..ec1d5e6bfc01 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/loading.tsx @@ -0,0 +1,9 @@ +import { Spinner } from './Spinner' + +export default function Loading() { + return ( +
    + +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/member/[id]/Activity.tsx b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Activity.tsx new file mode 100644 index 000000000000..e2238529af7e --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Activity.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getActivity, type ActivityItem } from '../../members' +import { Spinner } from '../../Spinner' + +export function Activity({ id }: { id: string }) { + const [items, setItems] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + getActivity(id).then((value) => { + setItems(value) + setIsLoading(false) + }) + }, [id]) + + if (isLoading) return + + return ( +
    +

    Activity

    +
      + {items.map((item) => ( +
    • {item.label}
    • + ))} +
    +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/member/[id]/Bio.tsx b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Bio.tsx new file mode 100644 index 000000000000..05bcf0564d3f --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Bio.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getMember, type Member } from '../../members' +import { Spinner } from '../../Spinner' + +export function Bio({ id }: { id: string }) { + const [member, setMember] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + getMember(id).then((value) => { + setMember(value) + setIsLoading(false) + }) + }, [id]) + + if (isLoading) return + if (!member) return

    Member not found.

    + + return ( +
    +

    {member.name}

    +

    {member.role}

    +

    {member.bio}

    +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/member/[id]/Related.tsx b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Related.tsx new file mode 100644 index 000000000000..b82d3ebc8907 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/member/[id]/Related.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useEffect, useState } from 'react' +import { getRelated, type Member } from '../../members' +import { Spinner } from '../../Spinner' + +export function Related({ id }: { id: string }) { + const [people, setPeople] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + getRelated(id).then((value) => { + setPeople(value) + setIsLoading(false) + }) + }, [id]) + + if (isLoading) return + + return ( +
    +

    Related people

    +
      + {people.map((person) => ( +
    • {person.name}
    • + ))} +
    +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/member/[id]/page.tsx b/evals/evals/agent-052-design-loading-ui/app/member/[id]/page.tsx new file mode 100644 index 000000000000..8853aa505c89 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/member/[id]/page.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link' +import { Activity } from './Activity' +import { Bio } from './Bio' +import { Related } from './Related' + +export default async function MemberPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + return ( +
    + ← Team directory + + + +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/members.ts b/evals/evals/agent-052-design-loading-ui/app/members.ts new file mode 100644 index 000000000000..d586f98b8caa --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/members.ts @@ -0,0 +1,93 @@ +export type Member = { + id: string + name: string + role: string + bio: string +} + +export type MemberPage = { members: Member[]; hasMore: boolean } +export type ActivityItem = { id: string; label: string } + +const MEMBERS: Member[] = [ + { + id: 'ada', + name: 'Ada Lovelace', + role: 'Mathematician', + bio: 'Wrote the first algorithm intended for a machine.', + }, + { + id: 'grace', + name: 'Grace Hopper', + role: 'Computer scientist', + bio: 'Pioneered machine-independent programming languages.', + }, + { + id: 'alan', + name: 'Alan Turing', + role: 'Computer scientist', + bio: 'Formalized computation and artificial intelligence.', + }, + { + id: 'katherine', + name: 'Katherine Johnson', + role: 'Mathematician', + bio: 'Calculated trajectories for human spaceflight.', + }, + { + id: 'claude', + name: 'Claude Shannon', + role: 'Information theorist', + bio: 'Founded the field of information theory.', + }, + { + id: 'barbara', + name: 'Barbara Liskov', + role: 'Language designer', + bio: 'Developed foundational ideas in data abstraction.', + }, + { + id: 'donald', + name: 'Donald Knuth', + role: 'Professor', + bio: 'Advanced the analysis of algorithms.', + }, + { + id: 'radia', + name: 'Radia Perlman', + role: 'Network engineer', + bio: 'Designed the spanning tree protocol.', + }, +] + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export async function getStats() { + await delay(250) + return { people: MEMBERS.length, teams: 4 } +} + +export async function getMembers(page = 1, query = ''): Promise { + await delay(query.length === 1 ? 700 : 450) + const matches = MEMBERS.filter((member) => + `${member.name} ${member.role}`.toLowerCase().includes(query.toLowerCase()) + ) + const end = page * 4 + return { members: matches.slice(0, end), hasMore: end < matches.length } +} + +export async function getMember(id: string) { + await delay(800) + return MEMBERS.find((member) => member.id === id) ?? null +} + +export async function getActivity(id: string): Promise { + await delay(300) + return [{ id: '1', label: `${id} shipped a design document` }] +} + +export async function getRelated(id: string) { + await delay(500) + return MEMBERS.filter((member) => member.id !== id).slice(0, 3) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/page.tsx b/evals/evals/agent-052-design-loading-ui/app/page.tsx new file mode 100644 index 000000000000..847756c65225 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/page.tsx @@ -0,0 +1,17 @@ +import { MemberList } from './MemberList' +import { getStats } from './members' +import { Stats } from './stats' + +export default async function Page() { + const stats = await getStats() + + return ( +
    +
    +

    Team directory

    + +
    + +
    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/app/stats.tsx b/evals/evals/agent-052-design-loading-ui/app/stats.tsx new file mode 100644 index 000000000000..006214a7d99b --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/app/stats.tsx @@ -0,0 +1,7 @@ +export function Stats({ people, teams }: { people: number; teams: number }) { + return ( +

    + {people} people · {teams} teams +

    + ) +} diff --git a/evals/evals/agent-052-design-loading-ui/next.config.ts b/evals/evals/agent-052-design-loading-ui/next.config.ts new file mode 100644 index 000000000000..e4f5738a310b --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/evals/evals/agent-052-design-loading-ui/package.json b/evals/evals/agent-052-design-loading-ui/package.json new file mode 100644 index 000000000000..7056dd9ed5f9 --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^16", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5", + "vitest": "^3.1.3", + "@vitejs/plugin-react": "^4.4.1", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/evals/evals/agent-052-design-loading-ui/tsconfig.json b/evals/evals/agent-052-design-loading-ui/tsconfig.json new file mode 100644 index 000000000000..cc321ed0658b --- /dev/null +++ b/evals/evals/agent-052-design-loading-ui/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", "EVAL.ts"] +} diff --git a/run-evals.js b/run-evals.js index 224811794de6..22fcea87fa72 100644 --- a/run-evals.js +++ b/run-evals.js @@ -72,8 +72,8 @@ function writeExperiments(evalName) { ${v.imports} const config: ExperimentConfig = { - agent: 'claude-code', - model: 'claude-opus-4-6',${evalsField} + agent: 'vercel-ai-gateway/claude-code', + model: 'claude-opus-4-8',${evalsField} scripts: ['build'], runs: 1, earlyExit: true, @@ -136,9 +136,6 @@ function main() { /** @type {string | null} */ const evalName = argv.all ? null : /** @type {string} */ (argv.evalName) - // Flags not consumed here are forwarded to agent-eval. - const forward = argv.dry ? ['--dry'] : [] - if (!fs.existsSync(path.join(ROOT, 'packages/next/dist'))) { console.error( 'packages/next/dist not found. Run `pnpm --filter=next build` first.' @@ -180,7 +177,10 @@ function main() { // the bin directly rather than via `pnpm exec` because pnpm resets cwd to // the workspace root, but agent-eval resolves experiments/ from process.cwd(). const bin = path.join(ROOT, 'node_modules/.bin/agent-eval') - const result = spawnSync(bin, ['run-all', '--force', ...forward], { + const command = argv.dry + ? ['status', 'baseline', 'agents-md'] + : ['run', 'baseline', 'agents-md', '--force'] + const result = spawnSync(bin, command, { cwd: EVALS_DIR, stdio: 'inherit', env: { ...process.env, NEXT_EVAL_TARBALL: TARBALL },