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
4 changes: 4 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ jobs:
export NEXT_TEST_MODE=dev
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
export RUST_BACKTRACE=1

node run-tests.js \
--test-pattern '^(test\/(development|e2e))/.*\.test\.(js|jsx|ts|tsx)$' \
Expand Down Expand Up @@ -287,6 +288,7 @@ jobs:
export TURBOPACK_DEV=1
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
export RUST_BACKTRACE=1

node run-tests.js \
--timings \
Expand Down Expand Up @@ -318,6 +320,7 @@ jobs:
export NEXT_TEST_MODE=start
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
export RUST_BACKTRACE=1

node run-tests.js --timings -g ${{ matrix.group }} --type production
stepName: 'test-turbopack-production-react-${{ matrix.react }}-${{ matrix.group }}'
Expand Down Expand Up @@ -346,6 +349,7 @@ jobs:
export TURBOPACK_BUILD=1
export NEXT_TEST_REACT_VERSION="${{ matrix.react }}"
export __NEXT_EXPERIMENTAL_STRICT_ROUTE_TYPES=true
export RUST_BACKTRACE=1

node run-tests.js \
--timings \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
name: turbopack-production
test_type: production
run_before_test: |
export IS_TURBOPACK_TEST=1 TURBOPACK_BUILD=1
export IS_TURBOPACK_TEST=1 TURBOPACK_BUILD=1 RUST_BACKTRACE=1
# Failing tests take longer (due to timeouts and retries). Since we have
# many failing tests, we need smaller groups and longer timeouts, in case
# a group gets stuck with a cluster of failing tests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ jobs:
name: turbopack-development
test_type: development
run_before_test: |
export IS_TURBOPACK_TEST=1 TURBOPACK_DEV=1
export IS_TURBOPACK_TEST=1 TURBOPACK_DEV=1 RUST_BACKTRACE=1
secrets: inherit
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,13 @@ See [Codebase structure](#codebase-structure) above for detailed explanations.
- Use `DEBUG=next:*` for debug logging
- Use `NEXT_TELEMETRY_DISABLED=1` when testing locally

### `NODE_ENV` vs `__NEXT_DEV_SERVER`

Both `next dev` and `next build --debug-prerender` produce bundles with `NODE_ENV=development`. Use `process.env.__NEXT_DEV_SERVER` to distinguish between them:

- `process.env.NODE_ENV !== 'production'` — code that should exist in dev bundles but be eliminated from prod bundles. This is a build-time check.
- `process.env.__NEXT_DEV_SERVER` — code that should only run with the dev server (`next dev`), not during `next build --debug-prerender` or `next start`.

## Commit and PR Style

- Do NOT add "Generated with Claude Code" or co-author footers to commits or PRs
Expand Down
7 changes: 7 additions & 0 deletions contributing/core/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ $ pnpm unpack-next path/to/project

The dev overlay is a feature of Next.js that allows you to see the internal state of the app including the errors. To learn more about contributing to the dev overlay, see the [Dev Overlay README.md](../../packages/next/src/client/components/react-dev-overlay/README.md).

## `NODE_ENV` vs `__NEXT_DEV_SERVER`

Both `next dev` and `next build --debug-prerender` produce bundles with `NODE_ENV=development`. Use `process.env.__NEXT_DEV_SERVER` to distinguish between them:

- `process.env.NODE_ENV !== 'production'` — code that should exist in dev bundles but be eliminated from prod bundles. This is a build-time check.
- `process.env.__NEXT_DEV_SERVER` — code that should only run with the dev server (`next dev`), not during `next build --debug-prerender` or `next start`.

## Recover disk space

Rust builds quickly add up to a lot of disk space, you can clean up old artifacts with this command:
Expand Down
2 changes: 1 addition & 1 deletion crates/next-api/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,7 @@ impl Project {
}

/// Build the `IssueFilter` for this project, incorporating any
/// `experimental.turbopackIgnoreIssue` rules from the Next.js config.
/// `turbopack.ignoreIssue` rules from the Next.js config.
#[turbo_tasks::function]
pub async fn issue_filter(self: Vc<Self>) -> Result<Vc<IssueFilter>> {
let ignore_rules = self.next_config().turbopack_ignore_issue_rules().await?;
Expand Down
20 changes: 10 additions & 10 deletions crates/next-core/src/next_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,9 @@ pub struct TurbopackConfig {
pub resolve_alias: Option<FxIndexMap<RcStr, JsonValue>>,
pub resolve_extensions: Option<Vec<RcStr>>,
pub debug_ids: Option<bool>,
/// Issue patterns to ignore (suppress) from Turbopack output.
#[serde(default)]
pub ignore_issue: Option<Vec<TurbopackIgnoreIssueRule>>,
}

#[derive(
Expand Down Expand Up @@ -970,7 +973,7 @@ pub enum ReactCompilerOptionsOrBoolean {
#[turbo_tasks::value(transparent)]
pub struct OptionalReactCompilerOptions(Option<ResolvedVc<ReactCompilerOptions>>);

/// Serialized representation of a path pattern for `turbopackIgnoreIssue`.
/// Serialized representation of a path pattern for `turbopack.ignoreIssue`.
/// Strings are serialized as `{ "type": "glob", "value": "..." }` and
/// RegExp as `{ "type": "regex", "source": "...", "flags": "..." }`.
#[derive(
Expand Down Expand Up @@ -998,7 +1001,7 @@ impl TurbopackIgnoreIssuePathPattern {
}

/// Serialized representation of a text pattern (title/description) for
/// `turbopackIgnoreIssue`. Strings are serialized as
/// `turbopack.ignoreIssue`. Strings are serialized as
/// `{ "type": "string", "value": "..." }` and RegExp as
/// `{ "type": "regex", "source": "...", "flags": "..." }`.
#[derive(
Expand All @@ -1025,7 +1028,7 @@ impl TurbopackIgnoreIssueTextPattern {
}
}

/// A single rule in `experimental.turbopackIgnoreIssue`.
/// A single rule in `turbopack.ignoreIssue`.
#[derive(
Clone, Debug, PartialEq, Deserialize, TraceRawVcs, NonLocalValue, OperationValue, Encode, Decode,
)]
Expand Down Expand Up @@ -1169,9 +1172,6 @@ pub struct ExperimentalConfig {
turbopack_remove_unused_exports: Option<bool>,
/// Enable local analysis to infer side effect free modules. Defaults to true.
turbopack_infer_module_side_effects: Option<bool>,
/// Issue patterns to ignore (suppress) from Turbopack output.
#[serde(default)]
turbopack_ignore_issue: Option<Vec<TurbopackIgnoreIssueRule>>,
/// Devtool option for the segment explorer.
devtool_segment_explorer: Option<bool>,
/// Whether to report inlined system environment variables as warnings or errors.
Expand Down Expand Up @@ -2211,14 +2211,14 @@ impl NextConfig {
}
}

/// Returns the list of ignore-issue rules from the experimental config,
/// Returns the list of ignore-issue rules from the turbopack config,
/// converted to the `IgnoreIssue` type used by `IssueFilter`.
#[turbo_tasks::function]
pub fn turbopack_ignore_issue_rules(&self) -> Result<Vc<IgnoreIssues>> {
let rules = self
.experimental
.turbopack_ignore_issue
.as_deref()
.turbopack
.as_ref()
.and_then(|tp| tp.ignore_issue.as_deref())
.unwrap_or_default()
.iter()
.map(|rule| {
Expand Down
4 changes: 0 additions & 4 deletions crates/next-core/src/next_shared/webpack_rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,6 @@ pub async fn webpack_loader_options(
.await?,
);

if rules.is_empty() {
return Ok(Vc::cell(None));
}

Ok(Vc::cell(Some(
WebpackLoadersOptions {
rules: ResolvedVc::cell(rules),
Expand Down
2 changes: 2 additions & 0 deletions docs/01-app/01-getting-started/06-cache-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,8 @@ This behavior improves the navigation experience by maintaining UI state (form i

> **Good to know**: Next.js uses heuristics to keep a few recently visited routes `"hidden"`, while older routes are removed from the DOM to prevent excessive growth.

Some UI patterns behave differently when components stay mounted instead of unmounting. See the [Activity with Cache Components guide](/docs/app/guides/activity-cache-components) for handling common patterns like dropdowns, dialogs, and testing.

## Migrating route segment configs

When Cache Components is enabled, several route segment config options are no longer needed or supported:
Expand Down
194 changes: 194 additions & 0 deletions docs/01-app/02-guides/activity-cache-components.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
title: Activity with Cache Components
nav_title: Activity with Cache Components
description: Learn how to handle UI patterns like dropdowns, dialogs, and forms that behave differently when Cache Components keeps routes mounted with Activity.
related:
title: Related
description: Learn more about Activity and Cache Components.
links:
- app/getting-started/cache-components
- app/guides/activity
---

When [Cache Components](/docs/app/getting-started/cache-components) is enabled, routes don't unmount when you navigate away from them. Instead, Next.js uses React's [`<Activity>`](https://react.dev/reference/react/Activity) component to hide them - setting them to `mode="hidden"` rather than removing them from the tree.

This preserves component state and DOM state, which is great for back/forward navigation. But it can also surprise you: patterns that relied on components unmounting to reset state will now behave differently.

Next.js uses a **3-entry limit** - it preserves up to 3 routes. When you navigate beyond that, the oldest route's DOM and React state are dropped. This means if a user navigates through many pages and then goes back, very old states may have been evicted and will re-render fresh.

### Why Activity ships with Cache Components

Cache Components and Activity work together to enable SPA-like navigation - instant route transitions without sacrificing server rendering:

- **Server Components** use `"use cache"` to extend their lifetime, enabling prefetching and instant route transitions
- **Client Components** use Activity to preserve their state and DOM across navigations - without it, form inputs, scroll positions, and component state would reset on every navigation

This foundation enables full route prefetching, instant back/forward navigation, and upcoming features like View Transitions and Gesture APIs.

> **Good to know:** Opt-out strategies are being considered for gradual migration.

If you want to understand how Activity works and the patterns it enables, see the [Activity guide](/docs/app/guides/activity).

## Common issues and fixes

### Dropdown stays open after navigation

Consider a settings page with a dropdown menu. If a user opens the dropdown, navigates to another page, then navigates back, the dropdown is still open. Activity preserved the component state, including `isOpen: true`.

**Fix: Reset state when hidden**

Use Effect cleanup to close the dropdown when Activity hides the component:

```tsx highlight={8-13}
'use client'

import { useState, useLayoutEffect } from 'react'

function SettingsDropdown() {
const [isOpen, setIsOpen] = useState(false)

// Close dropdown when this component becomes hidden
useLayoutEffect(() => {
return () => {
setIsOpen(false)
}
}, [])

return (
<div>
<button onClick={() => setIsOpen((o) => !o)}>Options</button>
{isOpen && (
<ul>
<li>
<button>Edit Profile</button>
</li>
<li>
<button>Change Password</button>
</li>
</ul>
)}
</div>
)
}
```

When Activity hides this component, the cleanup function runs and resets `isOpen`. When the page becomes visible again, the dropdown is closed. Using `useLayoutEffect` ensures the cleanup runs synchronously before the component is hidden, avoiding any flash of stale state.

You can also use `Link`'s [`onNavigate`](/docs/app/api-reference/components/link#onnavigate) callback to close dropdowns immediately when a navigation link is clicked.

### Dialog doesn't re-run initialization logic

Consider a page with a dialog that focuses its first input when opened:

```tsx
'use client'

import { useState, useRef, useEffect } from 'react'

function ProductTab() {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
if (isDialogOpen) {
inputRef.current?.focus()
}
}, [isDialogOpen])

// ...
}
```

A user opens the dialog (input gets focused), closes it, navigates to another page, navigates back, and opens the dialog again. This time the input doesn't get focused.

The issue is subtle: if the dialog was closed before navigating away, the state is `false`. Returning and opening it sets `isDialogOpen` to `true`, triggering the Effect as expected. However, if the user navigated away _while the dialog was open_, Activity preserved `isDialogOpen: true`. Opening the dialog again sets it to `true` when it's already `true`. No state change means the Effect doesn't re-run.

**Fix: Derive dialog state from something that resets**

Make the open/closed state derive from something outside the preserved component state - like a search param:

```tsx highlight={3,7-9,20,25}
'use client'

import { useSearchParams, useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

function ProductTab() {
const searchParams = useSearchParams()
const router = useRouter()
const isDialogOpen = searchParams.get('edit') === 'true'
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
if (isDialogOpen) {
inputRef.current?.focus()
}
}, [isDialogOpen])

return (
<div>
<button onClick={() => router.push('?edit=true')}>Edit Product</button>

{isDialogOpen && (
<dialog open>
<input ref={inputRef} placeholder="Product name" />
<button onClick={() => router.replace('?', { scroll: false })}>
Close
</button>
</dialog>
)}
</div>
)
}
```

With this approach, `isDialogOpen` derives from the URL rather than component state. When navigating away and returning, the search param is cleared (the URL changed), so `isDialogOpen` becomes `false`. Opening the dialog sets the param, which changes `isDialogOpen` and triggers the Effect.

### Form state persists on "create" routes

A "create new item" page might show stale data when returning to it if form values were preserved by Activity.

**Fix: Use ref cleanup to reset on hide**

Use a callback ref to reset form fields when Activity hides the component:

```tsx
<form
ref={(form) => {
// Cleanup function - runs when Activity hides this component
return () => form?.reset()
}}
>
{/* fields */}
</form>
```

This resets the form whenever the user navigates away. See [Resetting state](/docs/app/guides/activity#resetting-state) in the Activity guide for more patterns, including key-based resets for React state.

## State and authentication

Activity preserves local component state (`useState`, DOM input values) across navigations, including authentication changes. This is standard React behavior - props changing (such as receiving a new user) triggers a re-render but does not reset existing state. If your application needs fresh state when users change, you can handle this explicitly.

For logout flows, using `window.location.href` instead of `router.push` triggers a full page reload, clearing all client-side state. For finer control, reset state when the user identity changes:

```tsx
'use client'

import { useState, useEffect, useRef } from 'react'

function UserScopedForm({ userId }: { userId: string | null }) {
const [draft, setDraft] = useState('')
const lastUserIdRef = useRef<string | null>(null)

useEffect(() => {
if (lastUserIdRef.current !== null && lastUserIdRef.current !== userId) {
setDraft('') // Reset on user change
}
lastUserIdRef.current = userId
}, [userId])

return <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
}
```

Alternatively, key components by user ID to let React handle the reset: `<Form key={userId} />`. See [Resetting state](/docs/app/guides/activity#resetting-state) for more patterns.
Loading
Loading