diff --git a/Cargo.lock b/Cargo.lock
index 2102ab754492..de2e72b70120 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2022,16 +2022,6 @@ version = "0.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483"
-[[package]]
-name = "ctrlc"
-version = "3.4.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
-dependencies = [
- "nix",
- "windows-sys 0.59.0",
-]
-
[[package]]
name = "cty"
version = "0.2.2"
@@ -4300,31 +4290,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
-[[package]]
-name = "lsp-server"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "248f65b78f6db5d8e1b1604b4098a28b43d21a8eb1deeca22b1c421b276c7095"
-dependencies = [
- "crossbeam-channel",
- "log",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "lsp-types"
-version = "0.95.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365"
-dependencies = [
- "bitflags 1.3.2",
- "serde",
- "serde_json",
- "serde_repr",
- "url",
-]
-
[[package]]
name = "lz4_flex"
version = "0.11.3"
@@ -7113,17 +7078,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "serde_repr"
-version = "0.1.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.104",
-]
-
[[package]]
name = "serde_spanned"
version = "0.6.9"
@@ -9859,27 +9813,6 @@ dependencies = [
"unty",
]
-[[package]]
-name = "turbo-static"
-version = "0.1.0"
-dependencies = [
- "bincode 1.3.3",
- "clap",
- "ctrlc",
- "ignore",
- "itertools 0.10.5",
- "lsp-server",
- "lsp-types",
- "proc-macro2",
- "rustc-hash 2.1.1",
- "serde",
- "serde_json",
- "serde_path_to_error",
- "syn 2.0.104",
- "tracing",
- "tracing-subscriber",
-]
-
[[package]]
name = "turbo-tasks"
version = "0.1.0"
diff --git a/packages/next/src/cli/internal/upload-trace.ts b/packages/next/src/cli/internal/upload-trace.ts
index 3ed3903b61b2..831d2ab43dc3 100644
--- a/packages/next/src/cli/internal/upload-trace.ts
+++ b/packages/next/src/cli/internal/upload-trace.ts
@@ -1,7 +1,7 @@
import path from 'path'
import fs from 'fs/promises'
-const UPLOAD_TRACE_URL = 'https://api.nextjs.org/api/upload-trace'
+const UPLOAD_TRACE_URL = 'https://nextjs.org/api/upload-trace'
// V8 CPU profiles are JSON objects starting with {"nodes":
const CPUPROFILE_HEADER = Buffer.from('{"nodes":')
diff --git a/packages/next/src/client/components/catch-error.tsx b/packages/next/src/client/components/catch-error.tsx
index e85fe29d7d4b..08ab813fcdc3 100644
--- a/packages/next/src/client/components/catch-error.tsx
+++ b/packages/next/src/client/components/catch-error.tsx
@@ -30,7 +30,7 @@ type CatchErrorProps
= {
}
type CatchErrorState = {
- error: Error | null
+ error: null | { thrownValue: unknown }
previousPathname: string | null
}
@@ -38,7 +38,7 @@ type CatchErrorState = {
// TODO: Extend it instead of forking to easily sync the behavior?
class CatchError
extends React.Component<
CatchErrorProps
,
- { error: Error | null; previousPathname: string | null }
+ CatchErrorState
> {
declare context: AppRouterInstance | null
static contextType = AppRouterContext
@@ -54,14 +54,16 @@ class CatchError
extends React.Component<
}
}
- static getDerivedStateFromError(error: Error) {
- if (isNextRouterError(error)) {
+ static getDerivedStateFromError(
+ thrownValue: unknown
+ ): Partial {
+ if (isNextRouterError(thrownValue)) {
// Re-throw if an expected internal Next.js router error occurs
// this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment)
- throw error
+ throw thrownValue
}
- return { error }
+ return { error: { thrownValue } }
}
static getDerivedStateFromProps(
@@ -75,7 +77,7 @@ class CatchError extends React.Component<
// the error boundary and instead should fallback
// to a hard navigation to attempt recovering
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
- if (error && handleHardNavError(error)) {
+ if (error && handleHardNavError(error.thrownValue)) {
// clear error so we don't render anything
return {
error: null,
@@ -124,13 +126,15 @@ class CatchError
extends React.Component<
//When it's bot request, segment level error boundary will keep rendering the children,
// the final error will be caught by the root error boundary and determine wether need to apply graceful degrade.
if (this.state.error && !isBotUserAgent) {
- handleISRError({ error: this.state.error })
+ const thrownValue = this.state.error.thrownValue
+ handleISRError({ error: thrownValue })
return (
void
unstable_retry: () => void
}
@@ -35,7 +35,7 @@ interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps {
}
interface ErrorBoundaryHandlerState {
- error: Error | null
+ error: null | { thrownValue: unknown }
previousPathname: string | null
}
@@ -54,14 +54,16 @@ export class ErrorBoundaryHandler extends React.Component<
}
}
- static getDerivedStateFromError(error: Error) {
- if (isNextRouterError(error)) {
+ static getDerivedStateFromError(
+ thrownValue: unknown
+ ): Partial {
+ if (isNextRouterError(thrownValue)) {
// Re-throw if an expected internal Next.js router error occurs
// this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment)
- throw error
+ throw thrownValue
}
- return { error }
+ return { error: { thrownValue } }
}
static getDerivedStateFromProps(
@@ -75,7 +77,7 @@ export class ErrorBoundaryHandler extends React.Component<
// the error boundary and instead should fallback
// to a hard navigation to attempt recovering
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
- if (error && handleHardNavError(error)) {
+ if (error && handleHardNavError(error.thrownValue)) {
// clear error so we don't render anything
return {
error: null,
@@ -118,14 +120,16 @@ export class ErrorBoundaryHandler extends React.Component<
//When it's bot request, segment level error boundary will keep rendering the children,
// the final error will be caught by the root error boundary and determine wether need to apply graceful degrade.
if (this.state.error && !isBotUserAgent) {
- handleISRError({ error: this.state.error })
+ const thrownValue = this.state.error.thrownValue
+ handleISRError({ error: thrownValue })
return (
<>
{this.props.errorStyles}
{this.props.errorScripts}
diff --git a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx
index 4f057fc9b0eb..2012422b0521 100644
--- a/packages/next/src/client/components/http-access-fallback/error-boundary.tsx
+++ b/packages/next/src/client/components/http-access-fallback/error-boundary.tsx
@@ -77,7 +77,7 @@ class HTTPAccessFallbackErrorBoundary extends React.Component<
}
}
- static getDerivedStateFromError(error: any) {
+ static getDerivedStateFromError(error: unknown) {
if (isHTTPAccessFallbackError(error)) {
const httpStatus = getAccessFallbackHTTPStatus(error)
return {
diff --git a/packages/next/src/client/components/nav-failure-handler.ts b/packages/next/src/client/components/nav-failure-handler.ts
index 795707c12f09..808c1b0fb03d 100644
--- a/packages/next/src/client/components/nav-failure-handler.ts
+++ b/packages/next/src/client/components/nav-failure-handler.ts
@@ -3,7 +3,6 @@ import { createHrefFromUrl } from './router-reducer/create-href-from-url'
export function handleHardNavError(error: unknown): boolean {
if (
- error &&
typeof window !== 'undefined' &&
window.next.__pendingUrl &&
createHrefFromUrl(new URL(window.location.href)) !==
diff --git a/packages/next/src/client/components/redirect-boundary.tsx b/packages/next/src/client/components/redirect-boundary.tsx
index 32f68f284e2c..0b17e62820f3 100644
--- a/packages/next/src/client/components/redirect-boundary.tsx
+++ b/packages/next/src/client/components/redirect-boundary.tsx
@@ -44,7 +44,7 @@ export class RedirectErrorBoundary extends React.Component<
this.state = { redirect: null, redirectType: null }
}
- static getDerivedStateFromError(error: any) {
+ static getDerivedStateFromError(error: unknown) {
if (isRedirectError(error)) {
const url = getURLFromRedirectError(error)
const redirectType = getRedirectTypeFromError(error)
diff --git a/packages/next/src/client/image-component.tsx b/packages/next/src/client/image-component.tsx
index 1e635c829354..7bace3ab334d 100644
--- a/packages/next/src/client/image-component.tsx
+++ b/packages/next/src/client/image-component.tsx
@@ -3,12 +3,12 @@
import React, {
useRef,
useEffect,
- useCallback,
useContext,
useMemo,
useState,
forwardRef,
use,
+ useLayoutEffect,
} from 'react'
import ReactDOM from 'react-dom'
import Head from '../shared/lib/head'
@@ -44,7 +44,7 @@ export type { ImageLoaderProps }
export type ImageLoader = (p: ImageLoaderProps) => string
type ImgElementWithDataProp = HTMLImageElement & {
- 'data-loaded-src': string | undefined
+ 'data-loaded-src'?: string | undefined
}
type ImageElementProps = ImgProps & {
@@ -180,6 +180,15 @@ function getDynamicProps(
return { fetchpriority: fetchPriority }
}
+/**
+ * A version of useLayoutEffect that doesn't warn during SSR.
+ * TODO: Just useLayoutEffect once support for React 18 is dropped.
+ * Do not rename this to "isomorphic layout effect". There is no such thing as
+ * an isomorphic Layout Effect since there is no Layout on the server
+ */
+const useNonWarningLayoutEffect =
+ typeof window === 'undefined' ? useEffect : useLayoutEffect
+
const ImageElement = forwardRef(
(
{
@@ -207,18 +216,25 @@ const ImageElement = forwardRef(
},
forwardedRef
) => {
- const ownRef = useCallback(
- (img: ImgElementWithDataProp | null) => {
- if (!img) {
- return
- }
+ const didInsertRef = useRef(false)
+ const insertedImgRef = useRef(null)
+
+ useNonWarningLayoutEffect(() => {
+ const { current: didInsert } = didInsertRef
+ const { current: img } = insertedImgRef
+
+ if (!didInsert && img !== null) {
+ // Replay events from during hydration that React doesn't replay.
if (onError) {
// If the image has an error before react hydrates, then the error is lost.
// The workaround is to wait until the image is mounted which is after hydration,
// then we set the src again to trigger the error handler (if there was an error).
+ // This doesn't just trigger the error handler but retries the whole request.
+ // TODO: Consider dispatching a synthetic event instead.
// eslint-disable-next-line no-self-assign
img.src = img.src
}
+
if (process.env.NODE_ENV !== 'production') {
if (!src) {
console.error(`Image is missing required "src" property:`, img)
@@ -240,22 +256,24 @@ const ImageElement = forwardRef(
sizesInput
)
}
- },
- [
- src,
- placeholder,
- onLoadRef,
- onLoadingCompleteRef,
- setBlurComplete,
- onError,
- unoptimized,
- sizesInput,
- ]
- )
+ didInsertRef.current = true
+ }
+ }, [
+ src,
+ placeholder,
+ onLoadRef,
+ onLoadingCompleteRef,
+ onError,
+ unoptimized,
+ sizesInput,
+ ])
- const ref = useMergedRef(forwardedRef, ownRef)
+ const ref = useMergedRef(forwardedRef, insertedImgRef)
return (
+ // If you move this element creation, also move the Layout Effect above
+ // reading from the ref. Otherwise we might run the Layout Effect when
+ // the current value isn't set to the HTMLImageElement instance.
(
src={src}
ref={ref}
onLoad={(event) => {
- const img = event.currentTarget as ImgElementWithDataProp
+ const currentImage = event.currentTarget
handleLoading(
- img,
+ currentImage,
placeholder,
onLoadRef,
onLoadingCompleteRef,
diff --git a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx
index e68391eb6ee5..cf1ca9394612 100644
--- a/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx
+++ b/packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx
@@ -9,6 +9,7 @@ import {
AppRouterContext,
type AppRouterInstance,
} from '../../../shared/lib/app-router-context.shared-runtime'
+import isError from '../../../lib/is-error'
type AppDevOverlayErrorBoundaryProps = {
children: React.ReactNode
@@ -16,33 +17,25 @@ type AppDevOverlayErrorBoundaryProps = {
}
type AppDevOverlayErrorBoundaryState = {
- reactError: unknown
+ error: null | { thrownValue: unknown }
}
function ErroredHtml({
globalError: [GlobalError, globalErrorStyles],
- error,
+ thrownValue,
reset,
unstable_retry,
}: {
globalError: GlobalErrorState
- error: unknown
+ thrownValue: unknown
reset: () => void
unstable_retry: () => void
}) {
- if (!error) {
- return (
-
-
-
-
- )
- }
return (
{globalErrorStyles}
@@ -58,20 +51,23 @@ export class AppDevOverlayErrorBoundary extends PureComponent<
declare context: AppRouterInstance | null
state: AppDevOverlayErrorBoundaryState = {
- reactError: null,
+ error: null,
}
- static getDerivedStateFromError(error: Error) {
+ static getDerivedStateFromError(
+ thrownValue: Error
+ ): Partial {
RuntimeErrorHandler.hadRuntimeError = true
return {
- reactError: error,
+ error: { thrownValue },
}
}
- componentDidCatch(err: Error) {
+ componentDidCatch(err: unknown) {
if (
process.env.NODE_ENV === 'development' &&
+ isError(err) &&
err.message === SEGMENT_EXPLORER_SIMULATED_ERROR_MESSAGE
) {
return
@@ -87,22 +83,25 @@ export class AppDevOverlayErrorBoundary extends PureComponent<
}
reset = () => {
- this.setState({ reactError: null })
+ this.setState({ error: null })
}
render() {
const { children, globalError } = this.props
- const { reactError } = this.state
+ const { error } = this.state
- const fallback = (
-
- )
+ if (error !== null) {
+ const thrownValue = error.thrownValue
+ return (
+
+ )
+ }
- return reactError !== null ? fallback : children
+ return children
}
}
diff --git a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx
index 3d61edcc2464..4ee4753a9cf9 100644
--- a/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx
+++ b/packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx
@@ -3,21 +3,25 @@ import React from 'react'
type PagesDevOverlayErrorBoundaryProps = {
children?: React.ReactNode
}
-type PagesDevOverlayErrorBoundaryState = { error: Error | null }
+type PagesDevOverlayErrorBoundaryState = {
+ hasError: boolean
+}
export class PagesDevOverlayErrorBoundary extends React.PureComponent<
PagesDevOverlayErrorBoundaryProps,
PagesDevOverlayErrorBoundaryState
> {
- state = { error: null }
+ state = { hasError: false }
- static getDerivedStateFromError(error: Error) {
- return { error }
+ static getDerivedStateFromError(
+ _: unknown
+ ): Partial {
+ return { hasError: true }
}
// Explicit type is needed to avoid the generated `.d.ts` having a wide return type that could be specific to the `@types/react` version.
render(): React.ReactNode {
// The component has to be unmounted or else it would continue to error
- return this.state.error ? null : this.props.children
+ return this.state.hasError ? null : this.props.children
}
}
diff --git a/packages/next/src/server/dev/hot-reloader-turbopack.ts b/packages/next/src/server/dev/hot-reloader-turbopack.ts
index 3b11ba9b0b6d..a105385fa7c4 100644
--- a/packages/next/src/server/dev/hot-reloader-turbopack.ts
+++ b/packages/next/src/server/dev/hot-reloader-turbopack.ts
@@ -82,10 +82,7 @@ import { isAppPageRouteDefinition } from '../route-definitions/app-page-route-de
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import type { ModernSourceMapPayload } from '../lib/source-maps'
import { isDeferredEntry } from '../../build/entries'
-import {
- isMetadataRoute,
- isMetadataRouteFile,
-} from '../../lib/metadata/is-metadata-route'
+import { isMetadataRouteFile } from '../../lib/metadata/is-metadata-route'
import { setBundlerFindSourceMapImplementation } from '../patch-error-inspect'
import { getNextErrorFeedbackMiddleware } from '../../next-devtools/server/get-next-error-feedback-middleware'
import {
@@ -602,20 +599,15 @@ export async function createHotReloaderTurbopack(
join(distDir, p)
)
- const { type: entryType, page: entryPage } = splitEntryKey(key)
+ const { type: entryType } = splitEntryKey(key)
// Server HMR applies to App Router entries built with the Turbopack Node.js
- // runtime: app pages and regular route handlers. Edge routes, Pages Router
- // pages, middleware/instrumentation, and metadata routes (manifest.ts,
- // robots.ts, sitemap.ts, icon.tsx, etc.) are excluded. Metadata routes are
- // excluded because they serve HTTP responses directly and must re-execute
- // on every request to pick up file changes; the in-place module update
- // model of Server HMR does not apply to them.
+ // runtime: app pages and route handlers (including metadata routes). Edge
+ // routes, Pages Router pages, and middleware/instrumentation are excluded.
const usesServerHmr =
serverFastRefresh &&
entryType === 'app' &&
- writtenEndpoint.type !== 'edge' &&
- !isMetadataRoute(entryPage)
+ writtenEndpoint.type !== 'edge'
const filesToDelete: string[] = []
for (const file of serverPaths) {
@@ -623,9 +615,10 @@ export async function createHotReloaderTurbopack(
const relativePath = relative(distDir, file)
if (
- // For Pages Router, edge routes, middleware, and manifest files:
- // clear the sharedCache in evalManifest(), Node.js require.cache,
- // and edge runtime module contexts.
+ // For Pages Router, edge routes, middleware, and any entry not
+ // participating in server HMR: clear the sharedCache in
+ // evalManifest(), Node.js require.cache, and edge runtime module
+ // contexts.
force ||
!usesServerHmr ||
!serverHmrSubscriptions?.has(relativePath)
diff --git a/test/development/app-dir/server-hmr/app/manifest-dep.ts b/test/development/app-dir/server-hmr/app/manifest-dep.ts
new file mode 100644
index 000000000000..93327e4f2ee8
--- /dev/null
+++ b/test/development/app-dir/server-hmr/app/manifest-dep.ts
@@ -0,0 +1,2 @@
+export const depEvaluatedAt = Date.now()
+export const manifestVersion = 'Version 0'
diff --git a/test/development/app-dir/server-hmr/app/manifest.ts b/test/development/app-dir/server-hmr/app/manifest.ts
index c24c6ade4365..9aee60abf3e8 100644
--- a/test/development/app-dir/server-hmr/app/manifest.ts
+++ b/test/development/app-dir/server-hmr/app/manifest.ts
@@ -1,10 +1,15 @@
-import type { MetadataRoute } from 'next'
+import { depEvaluatedAt, manifestVersion } from './manifest-dep'
-export default function manifest(): MetadataRoute.Manifest {
+let _hmrTrigger = 0
+const manifestEvaluatedAt = Date.now()
+
+export default function manifest() {
return {
- name: 'Version 0',
+ name: manifestVersion,
short_name: 'v0',
start_url: '/',
display: 'standalone',
+ depEvaluatedAt,
+ manifestEvaluatedAt,
}
}
diff --git a/test/development/app-dir/server-hmr/server-hmr.test.ts b/test/development/app-dir/server-hmr/server-hmr.test.ts
index d013f9b37621..e438714661c2 100644
--- a/test/development/app-dir/server-hmr/server-hmr.test.ts
+++ b/test/development/app-dir/server-hmr/server-hmr.test.ts
@@ -194,13 +194,13 @@ describe('server-hmr', () => {
}
)
- it('reflects manifest.ts changes on fetch/refresh', async () => {
+ it('reflects manifest dep changes on fetch/refresh', async () => {
const initial = await next
.fetch('/manifest.webmanifest')
.then((res) => res.json())
expect(initial.name).toBe('Version 0')
- await next.patchFile('app/manifest.ts', (content) =>
+ await next.patchFile('app/manifest-dep.ts', (content) =>
content.replace('Version 0', 'Version 1')
)
@@ -211,6 +211,33 @@ describe('server-hmr', () => {
expect(updated.name).toBe('Version 1')
})
})
+
+ itTurbopackDev(
+ 'does not re-evaluate an unmodified dep when manifest changes',
+ async () => {
+ const initial = await next
+ .fetch('/manifest.webmanifest')
+ .then((res) => res.json())
+ const initialDepEvaluatedAt = initial.depEvaluatedAt
+
+ // Patch manifest.ts itself, not the dep module
+ await next.patchFile('app/manifest.ts', (content) =>
+ content.replace('_hmrTrigger = 0', '_hmrTrigger = 1')
+ )
+
+ await retry(async () => {
+ const updated = await next
+ .fetch('/manifest.webmanifest')
+ .then((res) => res.json())
+ // manifest.ts should have been re-evaluated (new timestamp)
+ expect(updated.manifestEvaluatedAt).not.toBe(
+ initial.manifestEvaluatedAt
+ )
+ // manifest-dep.ts should NOT have been re-evaluated
+ expect(updated.depEvaluatedAt).toBe(initialDepEvaluatedAt)
+ })
+ }
+ )
})
describe('route handler hmr', () => {
diff --git a/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx
index e5b75997081d..f459c29bba49 100644
--- a/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx
+++ b/test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx
@@ -9,7 +9,7 @@ export function ErrorFallback(
) {
return (
<>
- {error.message}
+ {(error as Error).message}
{props.title}
reset()}>
Reset
diff --git a/test/e2e/app-dir/catch-error/app/client-component/throw-null/page.tsx b/test/e2e/app-dir/catch-error/app/client-component/throw-null/page.tsx
new file mode 100644
index 000000000000..6df00711bd1c
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/client-component/throw-null/page.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { useState } from 'react'
+import type { ErrorInfo } from 'next/error'
+import { unstable_catchError } from 'next/error'
+
+function Inner() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw null
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
+
+function ErrorFallback(_props: {}, { error }: ErrorInfo) {
+ return {`An error occurred: ${error}`}
+}
+
+const Wrapped = unstable_catchError(ErrorFallback)
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx b/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx
new file mode 100644
index 000000000000..cf444431ad4f
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/client-component/throw-undefined/page.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { useState } from 'react'
+import type { ErrorInfo } from 'next/error'
+import { unstable_catchError } from 'next/error'
+
+function Inner() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw undefined
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
+
+function ErrorFallback(_props: {}, { error }: ErrorInfo) {
+ return {`An error occurred: ${error}`}
+}
+
+const Wrapped = unstable_catchError(ErrorFallback)
+
+export default function Page() {
+ return (
+
+
+
+ )
+}
diff --git a/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx
index e3879f6facf0..af731f397857 100644
--- a/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx
+++ b/test/e2e/app-dir/catch-error/app/server-component/catch-error-wrapper.tsx
@@ -8,7 +8,7 @@ export function ErrorFallback(
) {
return (
<>
- {error.message}
+ {(error as Error).message}
{props.title}
reset()}>
Reset
diff --git a/test/e2e/app-dir/catch-error/app/server-component/throw-null/error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/server-component/throw-null/error-wrapper.tsx
new file mode 100644
index 000000000000..4b3343e0ba7b
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/server-component/throw-null/error-wrapper.tsx
@@ -0,0 +1,10 @@
+'use client'
+
+import type { ErrorInfo } from 'next/error'
+import { unstable_catchError } from 'next/error'
+
+function ErrorFallback(_props: {}, { error }: ErrorInfo) {
+ return {`An error occurred: ${error}`}
+}
+
+export default unstable_catchError(ErrorFallback)
diff --git a/test/e2e/app-dir/catch-error/app/server-component/throw-null/page.tsx b/test/e2e/app-dir/catch-error/app/server-component/throw-null/page.tsx
new file mode 100644
index 000000000000..0f717e31a44b
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/server-component/throw-null/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+import { connection } from 'next/server'
+import ErrorWrapper from './error-wrapper'
+
+export default function Page() {
+ return (
+
+
+
+
+
+ )
+}
+
+async function PageImpl(): Promise {
+ await connection()
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw null
+}
diff --git a/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/error-wrapper.tsx b/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/error-wrapper.tsx
new file mode 100644
index 000000000000..4b3343e0ba7b
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/error-wrapper.tsx
@@ -0,0 +1,10 @@
+'use client'
+
+import type { ErrorInfo } from 'next/error'
+import { unstable_catchError } from 'next/error'
+
+function ErrorFallback(_props: {}, { error }: ErrorInfo) {
+ return {`An error occurred: ${error}`}
+}
+
+export default unstable_catchError(ErrorFallback)
diff --git a/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/page.tsx b/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/page.tsx
new file mode 100644
index 000000000000..cb11dbbd2193
--- /dev/null
+++ b/test/e2e/app-dir/catch-error/app/server-component/throw-undefined/page.tsx
@@ -0,0 +1,19 @@
+import { Suspense } from 'react'
+import { connection } from 'next/server'
+import ErrorWrapper from './error-wrapper'
+
+export default function Page() {
+ return (
+
+
+
+
+
+ )
+}
+
+async function PageImpl(): Promise {
+ await connection()
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw undefined
+}
diff --git a/test/e2e/app-dir/catch-error/catch-error.test.ts b/test/e2e/app-dir/catch-error/catch-error.test.ts
index 20ba393e6156..8d5a2000d726 100644
--- a/test/e2e/app-dir/catch-error/catch-error.test.ts
+++ b/test/e2e/app-dir/catch-error/catch-error.test.ts
@@ -69,6 +69,48 @@ describe('app-dir - unstable_catchError', () => {
expect(await browser.elementByCss('#recover').text()).toBe('Recovered')
})
+ it('should render fallback when undefined is thrown from a Client Component', async () => {
+ const browser = await next.browser('/client-component/throw-undefined')
+
+ await browser.elementByCss('#error-trigger-button').click()
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe('An error occurred: undefined')
+ })
+
+ it('should render fallback when null is thrown from a Client Component', async () => {
+ const browser = await next.browser('/client-component/throw-null')
+
+ await browser.elementByCss('#error-trigger-button').click()
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe('An error occurred: null')
+ })
+
+ it('should render fallback when undefined is thrown from a Server Component', async () => {
+ const browser = await next.browser('/server-component/throw-undefined')
+ // non-error values thrown during rendering get wrapped in an Error when transported over RSC.
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe(
+ isNextDev
+ ? 'An error occurred: Error: undefined'
+ : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ )
+ })
+
+ it('should render fallback when null is thrown from a Server Component', async () => {
+ const browser = await next.browser('/server-component/throw-null')
+ // non-error values thrown during rendering get wrapped in an Error when transported over RSC.
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe(
+ isNextDev
+ ? 'An error occurred: Error: null'
+ : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ )
+ })
+
it('should recover after reset on Pages Router', async () => {
const browser = await next.browser('/pages-router')
diff --git a/test/e2e/app-dir/catch-error/pages/pages-router.tsx b/test/e2e/app-dir/catch-error/pages/pages-router.tsx
index 82c5b8c4a3de..8bc3088d2473 100644
--- a/test/e2e/app-dir/catch-error/pages/pages-router.tsx
+++ b/test/e2e/app-dir/catch-error/pages/pages-router.tsx
@@ -9,7 +9,7 @@ function ErrorFallback(
return (
<>
- {error.message}
+ {(error as Error).message}
{
diff --git a/test/e2e/app-dir/errors/app/client-component/throw-null/error.js b/test/e2e/app-dir/errors/app/client-component/throw-null/error.js
new file mode 100644
index 000000000000..9b4f7536053a
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/client-component/throw-null/error.js
@@ -0,0 +1,5 @@
+'use client'
+
+export default function ErrorBoundary({ error }) {
+ return {`An error occurred: ${error}`}
+}
diff --git a/test/e2e/app-dir/errors/app/client-component/throw-null/page.js b/test/e2e/app-dir/errors/app/client-component/throw-null/page.js
new file mode 100644
index 000000000000..ce9d13f0d391
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/client-component/throw-null/page.js
@@ -0,0 +1,21 @@
+'use client'
+
+import { useState } from 'react'
+
+export default function Page() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw null
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
diff --git a/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js b/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js
new file mode 100644
index 000000000000..9b4f7536053a
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/client-component/throw-undefined/error.js
@@ -0,0 +1,5 @@
+'use client'
+
+export default function ErrorBoundary({ error }) {
+ return {`An error occurred: ${error}`}
+}
diff --git a/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js b/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js
new file mode 100644
index 000000000000..b6c7d3adeb4e
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/client-component/throw-undefined/page.js
@@ -0,0 +1,21 @@
+'use client'
+
+import { useState } from 'react'
+
+export default function Page() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw undefined
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
diff --git a/test/e2e/app-dir/errors/app/server-component/throw-null/error.js b/test/e2e/app-dir/errors/app/server-component/throw-null/error.js
new file mode 100644
index 000000000000..d6b44d2baa00
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/server-component/throw-null/error.js
@@ -0,0 +1,10 @@
+'use client'
+
+export default function ErrorBoundary({ error }) {
+ return (
+
+
{`An error occurred: ${error}`}
+
{`${error?.digest}`}
+
+ )
+}
diff --git a/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js b/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js
new file mode 100644
index 000000000000..d6b44d2baa00
--- /dev/null
+++ b/test/e2e/app-dir/errors/app/server-component/throw-undefined/error.js
@@ -0,0 +1,10 @@
+'use client'
+
+export default function ErrorBoundary({ error }) {
+ return (
+
+
{`An error occurred: ${error}`}
+
{`${error?.digest}`}
+
+ )
+}
diff --git a/test/e2e/app-dir/errors/index.test.ts b/test/e2e/app-dir/errors/index.test.ts
index e45d61a10ef8..d21b06827c69 100644
--- a/test/e2e/app-dir/errors/index.test.ts
+++ b/test/e2e/app-dir/errors/index.test.ts
@@ -39,6 +39,44 @@ describe('app-dir - errors', () => {
expect(pageErrors).toEqual([])
})
+ it('should trigger error component when undefined is thrown from a client component in the browser', async () => {
+ const pageErrors: unknown[] = []
+ const browser = await next.browser('/client-component/throw-undefined', {
+ beforePageLoad: (page) => {
+ page.on('pageerror', (error: unknown) => {
+ pageErrors.push(error)
+ })
+ },
+ })
+ await browser.elementByCss('#error-trigger-button').click()
+
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe('An error occurred: undefined')
+
+ // Handled by custom error boundary.
+ expect(pageErrors).toEqual([])
+ })
+
+ it('should trigger error component when null is thrown from a client component in the browser', async () => {
+ const pageErrors: unknown[] = []
+ const browser = await next.browser('/client-component/throw-null', {
+ beforePageLoad: (page) => {
+ page.on('pageerror', (error: unknown) => {
+ pageErrors.push(error)
+ })
+ },
+ })
+ await browser.elementByCss('#error-trigger-button').click()
+
+ expect(
+ await browser.waitForElementByCss('#error-boundary-message').text()
+ ).toBe('An error occurred: null')
+
+ // Handled by custom error boundary.
+ expect(pageErrors).toEqual([])
+ })
+
it('should trigger error component when an error happens during server components rendering', async () => {
const pageErrors: unknown[] = []
const browser = await next.browser('/server-component', {
@@ -97,12 +135,14 @@ describe('app-dir - errors', () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/server-component/throw-undefined')
+ // Non-error values thrown during rendering get wrapped in an Error when transported over RSC,
+ // so we expect an error object with a digest.
expect(
await browser.waitForElementByCss('#error-boundary-message').text()
).toBe(
isNextDev
- ? 'undefined'
- : 'Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ ? 'An error occurred: Error: undefined'
+ : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
)
expect(
await browser.waitForElementByCss('#error-boundary-digest').text()
@@ -132,12 +172,15 @@ describe('app-dir - errors', () => {
const outputIndex = next.cliOutput.length
const browser = await next.browser('/server-component/throw-null')
+ // Non-error values thrown during rendering get wrapped in an Error when transported over RSC,
+ // so we expect an error object with a digest.
+
expect(
await browser.waitForElementByCss('#error-boundary-message').text()
).toBe(
isNextDev
- ? 'null'
- : 'Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ ? 'An error occurred: Error: null'
+ : 'An error occurred: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
)
expect(
await browser.waitForElementByCss('#error-boundary-digest').text()
diff --git a/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js b/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js
new file mode 100644
index 000000000000..ce9d13f0d391
--- /dev/null
+++ b/test/e2e/app-dir/global-error/basic/app/client-throw-null/page.js
@@ -0,0 +1,21 @@
+'use client'
+
+import { useState } from 'react'
+
+export default function Page() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw null
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
diff --git a/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js b/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js
new file mode 100644
index 000000000000..b6c7d3adeb4e
--- /dev/null
+++ b/test/e2e/app-dir/global-error/basic/app/client-throw-undefined/page.js
@@ -0,0 +1,21 @@
+'use client'
+
+import { useState } from 'react'
+
+export default function Page() {
+ const [clicked, setClicked] = useState(false)
+ if (clicked) {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw undefined
+ }
+ return (
+ {
+ setClicked(true)
+ }}
+ >
+ Trigger Error!
+
+ )
+}
diff --git a/test/e2e/app-dir/global-error/basic/app/global-error.js b/test/e2e/app-dir/global-error/basic/app/global-error.js
index 40d18f27cf4d..1b1def2f79ff 100644
--- a/test/e2e/app-dir/global-error/basic/app/global-error.js
+++ b/test/e2e/app-dir/global-error/basic/app/global-error.js
@@ -6,7 +6,7 @@ export default function GlobalError({ error }) {
Global Error
- {`Global error: ${error?.message}`}
+ {`Global error: ${error}`}
{error?.digest && {error?.digest}
}
diff --git a/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js b/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js
new file mode 100644
index 000000000000..e77bd6455174
--- /dev/null
+++ b/test/e2e/app-dir/global-error/basic/app/rsc-throw-null/page.js
@@ -0,0 +1,4 @@
+export default function page() {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw null
+}
diff --git a/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js b/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js
new file mode 100644
index 000000000000..aee20ec6156f
--- /dev/null
+++ b/test/e2e/app-dir/global-error/basic/app/rsc-throw-undefined/page.js
@@ -0,0 +1,4 @@
+export default function page() {
+ // eslint-disable-next-line no-throw-literal -- testing bad values on purpose
+ throw undefined
+}
diff --git a/test/e2e/app-dir/global-error/basic/index.test.ts b/test/e2e/app-dir/global-error/basic/index.test.ts
index d49cd5aca2b7..2aa70f412713 100644
--- a/test/e2e/app-dir/global-error/basic/index.test.ts
+++ b/test/e2e/app-dir/global-error/basic/index.test.ts
@@ -28,7 +28,7 @@ describe('app dir - global-error', () => {
`)
}
expect(await browser.elementByCss('#error').text()).toBe(
- 'Global error: Client error'
+ 'Global error: Error: Client error'
)
})
@@ -54,8 +54,8 @@ describe('app dir - global-error', () => {
// Show original error message in dev mode, but hide with the react fallback RSC error message in production mode
expect(await browser.elementByCss('#error').text()).toBe(
isNextDev
- ? 'Global error: server page error'
- : 'Global error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ ? 'Global error: Error: server page error'
+ : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
)
expect(await browser.elementByCss('#digest').text()).toMatch(/\w+/)
})
@@ -80,12 +80,58 @@ describe('app dir - global-error', () => {
}
expect(await browser.elementByCss('h1').text()).toBe('Global Error')
expect(await browser.elementByCss('#error').text()).toBe(
- 'Global error: client page error'
+ 'Global error: Error: client page error'
)
expect(await browser.hasElementByCssSelector('#digest')).toBeFalsy()
})
+ it('should render global error when undefined is thrown in a server component', async () => {
+ const browser = await next.browser('/rsc-throw-undefined')
+ // Non-error values thrown during RSC render get wrapped in an Error when transported.
+ expect(await browser.waitForElementByCss('#error').text()).toBe(
+ isNextDev
+ ? 'Global error: Error: undefined'
+ : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ )
+ expect(await browser.elementByCss('h1').text()).toBe('Global Error')
+ })
+
+ it('should render global error when null is thrown in a server component', async () => {
+ const browser = await next.browser('/rsc-throw-null')
+ // Non-error values thrown during RSC render get wrapped in an Error when transported.
+ expect(await browser.waitForElementByCss('#error').text()).toBe(
+ isNextDev
+ ? 'Global error: Error: null'
+ : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ )
+ expect(await browser.elementByCss('h1').text()).toBe('Global Error')
+ })
+
+ it('should render global error when undefined is thrown in a client component', async () => {
+ const browser = await next.browser('/client-throw-undefined')
+ await browser
+ .waitForElementByCss('#error-trigger-button')
+ .elementByCss('#error-trigger-button')
+ .click()
+ expect(await browser.waitForElementByCss('#error').text()).toBe(
+ 'Global error: undefined'
+ )
+ expect(await browser.elementByCss('h1').text()).toBe('Global Error')
+ })
+
+ it('should render global error when null is thrown in a client component', async () => {
+ const browser = await next.browser('/client-throw-null')
+ await browser
+ .waitForElementByCss('#error-trigger-button')
+ .elementByCss('#error-trigger-button')
+ .click()
+ expect(await browser.waitForElementByCss('#error').text()).toBe(
+ 'Global error: null'
+ )
+ expect(await browser.elementByCss('h1').text()).toBe('Global Error')
+ })
+
it('should catch metadata error in error boundary if presented', async () => {
const browser = await next.browser('/metadata-error-with-boundary')
@@ -116,8 +162,8 @@ describe('app dir - global-error', () => {
expect(await browser.elementByCss('h1').text()).toBe('Global Error')
expect(await browser.elementByCss('#error').text()).toBe(
isNextDev
- ? 'Global error: Metadata error'
- : 'Global error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
+ ? 'Global error: Error: Metadata error'
+ : 'Global error: Error: Minified React error #441; visit https://react.dev/errors/441 for the full message or use the non-minified dev environment for full errors and additional helpful warnings.'
)
})
@@ -140,7 +186,7 @@ describe('app dir - global-error', () => {
}
expect(await browser.elementByCss('h1').text()).toBe('Global Error')
expect(await browser.elementByCss('#error').text()).toBe(
- 'Global error: nested error'
+ 'Global error: Error: nested error'
)
})
})
diff --git a/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx b/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx
new file mode 100644
index 000000000000..e195d869d2db
--- /dev/null
+++ b/test/e2e/app-dir/next-image-events/app/fulfilled/page.tsx
@@ -0,0 +1,52 @@
+'use client'
+'use no memo'
+
+import { useReducer, useState } from 'react'
+import Image from 'next/image'
+
+export default function Page() {
+ const [, setLoadEvent] = useState(null)
+ const [showClientImage, setShowClientImage] = useState(false)
+ const [, rerender] = useReducer((i) => i + 1, 0)
+
+ return (
+ <>
+ {}}
+ onLoad={(event) => {
+ console.error('hydrated image load')
+ // This doesn't really make sense. We just want to check rerendering
+ // doesn't infinitely loop
+ setLoadEvent(event)
+ }}
+ />
+
+ rerender Page
+
+ setShowClientImage(true)}>
+ Show Client image
+
+ {showClientImage && (
+ {}}
+ onLoad={(event) => {
+ console.error('client rendered image load')
+ // This doesn't really make sense. We just want to check rerendering
+ // doesn't infinitely loop
+ setLoadEvent(event)
+ }}
+ />
+ )}
+ >
+ )
+}
diff --git a/test/e2e/app-dir/next-image-events/app/layout.tsx b/test/e2e/app-dir/next-image-events/app/layout.tsx
new file mode 100644
index 000000000000..888614deda3b
--- /dev/null
+++ b/test/e2e/app-dir/next-image-events/app/layout.tsx
@@ -0,0 +1,8 @@
+import { ReactNode } from 'react'
+export default function Root({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/test/e2e/app-dir/next-image-events/app/rejected/page.tsx b/test/e2e/app-dir/next-image-events/app/rejected/page.tsx
new file mode 100644
index 000000000000..cb6a7ed2aac1
--- /dev/null
+++ b/test/e2e/app-dir/next-image-events/app/rejected/page.tsx
@@ -0,0 +1,50 @@
+'use client'
+'use no memo'
+
+import { useReducer, useState } from 'react'
+import Image from 'next/image'
+
+export default function Page() {
+ const [, setErrorEvent] = useState(null)
+ const [showClientImage, setShowClientImage] = useState(false)
+ const [, rerender] = useReducer((i) => i + 1, 0)
+
+ return (
+ <>
+ {
+ console.error('hydrated image error')
+ // This doesn't really make sense. We just want to check rerendering
+ // doesn't infinitely loop
+ setErrorEvent(event)
+ }}
+ />
+
+ rerender Page
+
+ setShowClientImage(true)}>
+ Show Client image
+
+ {showClientImage && (
+ {
+ console.error('client rendered image error')
+ // This doesn't really make sense. We just want to check rerendering
+ // doesn't infinitely loop
+ setErrorEvent(event)
+ }}
+ />
+ )}
+ >
+ )
+}
diff --git a/test/e2e/app-dir/next-image-events/next-image-events.test.ts b/test/e2e/app-dir/next-image-events/next-image-events.test.ts
new file mode 100644
index 000000000000..0ccf7e1b1b74
--- /dev/null
+++ b/test/e2e/app-dir/next-image-events/next-image-events.test.ts
@@ -0,0 +1,147 @@
+import { nextTestSetup } from 'e2e-utils'
+import { retry } from 'next-test-utils'
+
+describe('next-image-events', () => {
+ const { next } = nextTestSetup({
+ files: __dirname,
+ })
+
+ it('should not call onLoad multiple times', async () => {
+ const imageRequests = []
+ const browser = await next.browser('/fulfilled', {
+ beforePageLoad(page) {
+ page.on('request', (request) => {
+ if (request.resourceType() === 'image') {
+ imageRequests.push(request.url())
+ }
+ })
+ },
+ })
+
+ let logsIdx = 0
+ await retry(async () => {
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([
+ {
+ source: 'error',
+ message: 'hydrated image load',
+ },
+ ])
+ logsIdx = logs.length
+ })
+ expect(imageRequests).toEqual([expect.stringContaining('test')])
+ imageRequests.length = 0
+
+ await browser.locator(':text("Show Client image")').click()
+
+ await retry(async () => {
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([
+ {
+ source: 'error',
+ message: 'client rendered image load',
+ },
+ ])
+ logsIdx = logs.length
+ })
+ expect(imageRequests).toEqual([expect.stringContaining('test')])
+ imageRequests.length = 0
+
+ await browser.locator(':text("rerender Page")').click()
+
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([])
+ expect(imageRequests).toEqual([])
+ imageRequests.length = 0
+ })
+
+ it('should not infinitely retry on error', async () => {
+ const nextImageRequests: string[] = []
+ let logsIdx = 0
+ const browser = await next.browser('/rejected', {
+ beforePageLoad(page) {
+ // We're manually aborting here to simulate an error before React hydrates.
+ // A real request might settle too late to test this behavior reliably.
+ // Especially in dev, requests (even if warm) may take longer than hydration.
+ page.route(
+ /\/will-never-exist\.png|\/still-doesnt-exist\.png/,
+ (route) => {
+ nextImageRequests.push(route.request().url())
+ return route.abort()
+ }
+ )
+ },
+ })
+
+ await retry(async () => {
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([
+ {
+ source: 'error',
+ message: 'Failed to load resource: net::ERR_FAILED',
+ },
+ // Next.js retries once to trigger onError on SSRed, settled images.
+ // If this test fails, we'll either hydrated faster than the request settled,
+ // or dropped the retrying behavior of next/image
+ {
+ source: 'error',
+ message: 'Failed to load resource: net::ERR_FAILED',
+ },
+ {
+ source: 'error',
+ message: 'hydrated image error',
+ },
+ ])
+ logsIdx = logs.length
+ })
+ expect(nextImageRequests).toEqual([
+ expect.stringContaining('will-never-exist'),
+ // Next.js retries once to trigger onError on SSRed, settled images.
+ // If this test fails, we'll either hydrated faster than the request settled,
+ // or dropped the retrying behavior of next/image
+ expect.stringContaining('will-never-exist'),
+ ])
+ nextImageRequests.length = 0
+
+ await browser.locator(':text("Show Client image")').click()
+
+ await retry(async () => {
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([
+ {
+ source: 'error',
+ message: 'Failed to load resource: net::ERR_FAILED',
+ },
+ {
+ source: 'error',
+ message: 'client rendered image error',
+ },
+ ])
+ logsIdx = logs.length
+ })
+ expect(nextImageRequests).toEqual([
+ expect.stringContaining('still-doesnt-exist'),
+ ])
+ nextImageRequests.length = 0
+
+ await browser.locator(':text("rerender Page")').click()
+
+ const logs = await browser.log()
+ expect(
+ logs.slice(logsIdx).filter(({ source }) => source === 'error')
+ ).toEqual([])
+ expect(nextImageRequests).toEqual([])
+ nextImageRequests.length = 0
+ logsIdx = logs.length
+ })
+})
diff --git a/test/e2e/app-dir/next-image-events/next.config.js b/test/e2e/app-dir/next-image-events/next.config.js
new file mode 100644
index 000000000000..807126e4cf0b
--- /dev/null
+++ b/test/e2e/app-dir/next-image-events/next.config.js
@@ -0,0 +1,6 @@
+/**
+ * @type {import('next').NextConfig}
+ */
+const nextConfig = {}
+
+module.exports = nextConfig
diff --git a/test/e2e/app-dir/next-image-events/public/test.png b/test/e2e/app-dir/next-image-events/public/test.png
new file mode 100644
index 000000000000..e14fafc5cf3b
Binary files /dev/null and b/test/e2e/app-dir/next-image-events/public/test.png differ
diff --git a/turbopack/crates/turbo-static/.gitignore b/turbopack/crates/turbo-static/.gitignore
deleted file mode 100644
index 32d96908cdc6..000000000000
--- a/turbopack/crates/turbo-static/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-call_resolver.bincode
-graph.cypherl
diff --git a/turbopack/crates/turbo-static/Cargo.toml b/turbopack/crates/turbo-static/Cargo.toml
deleted file mode 100644
index 36b4b3d5da64..000000000000
--- a/turbopack/crates/turbo-static/Cargo.toml
+++ /dev/null
@@ -1,25 +0,0 @@
-[package]
-name = "turbo-static"
-version = "0.1.0"
-edition = "2024"
-license = "MIT"
-
-[dependencies]
-bincode = "1.3.3"
-clap = { workspace = true, features = ["derive"] }
-ctrlc = "3.4.4"
-ignore = "0.4.22"
-itertools.workspace = true
-lsp-server = "0.7.6"
-lsp-types = "0.95.1"
-proc-macro2 = { workspace = true, features = ["span-locations"] }
-rustc-hash = { workspace = true }
-serde = { workspace = true, features = ["derive"] }
-serde_json.workspace = true
-serde_path_to_error = "0.1.16"
-syn = { version = "2", features = ["parsing", "full", "visit", "extra-traits"] }
-tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
-tracing.workspace = true
-
-[lints]
-workspace = true
diff --git a/turbopack/crates/turbo-static/readme.md b/turbopack/crates/turbo-static/readme.md
deleted file mode 100644
index ab9b74ab266d..000000000000
--- a/turbopack/crates/turbo-static/readme.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Turbo Static
-
-Leverages rust-analyzer to build a complete view into the static dependency
-graph for your turbo tasks project.
-
-## How it works
-
-- find all occurrences of #[turbo_tasks::function] across all the packages you
- want to query
-- for each of the tasks we find, query rust analyzer to see which tasks call
- them
-- apply some very basis control flow analysis to determine whether the call is
- made 1 time, 0/1 times, or 0+ times, corresponding to direct calls,
- conditionals, or for loops
-- produce a cypher file that can be loaded into a graph database to query the
- static dependency graph
-
-## Usage
-
-This uses an in memory persisted database to cache rust-analyzer queries.
-To reset the cache, pass the `--reindex` flag. Running will produce a
-`graph.cypherl` file which can be loaded into any cypher-compatible database.
-
-```bash
-# pass in the root folders you want to analyze. the system will recursively
-# parse all rust code looking for turbo tasks functions
-cargo run --release -- ../../../turbo ../../../next.js
-# now you can load graph.cypherl into your database of choice, such as neo4j
-docker run \
- --publish=7474:7474 --publish=7687:7687 \
- --volume=$HOME/neo4j/data:/data \
- neo4j
-```
diff --git a/turbopack/crates/turbo-static/src/call_resolver.rs b/turbopack/crates/turbo-static/src/call_resolver.rs
deleted file mode 100644
index a44418f5d667..000000000000
--- a/turbopack/crates/turbo-static/src/call_resolver.rs
+++ /dev/null
@@ -1,166 +0,0 @@
-use std::{fs::OpenOptions, path::PathBuf};
-
-use rustc_hash::FxHashMap;
-
-use crate::{Identifier, IdentifierReference, lsp_client::RAClient};
-
-/// A wrapper around a rust-analyzer client that can resolve call references.
-/// This is quite expensive so we cache the results in an on-disk key-value
-/// store.
-pub struct CallResolver<'a> {
- client: &'a mut RAClient,
- state: FxHashMap>,
- path: Option,
-}
-
-/// On drop, serialize the state to disk
-impl Drop for CallResolver<'_> {
- fn drop(&mut self) {
- let file = OpenOptions::new()
- .create(true)
- .truncate(false)
- .write(true)
- .open(self.path.as_ref().unwrap())
- .unwrap();
- bincode::serialize_into(file, &self.state).unwrap();
- }
-}
-
-impl<'a> CallResolver<'a> {
- pub fn new(client: &'a mut RAClient, path: Option) -> Self {
- // load bincode-encoded FxHashMap from path
- let state = path
- .as_ref()
- .and_then(|path| {
- let file = OpenOptions::new()
- .create(true)
- .truncate(false)
- .read(true)
- .write(true)
- .open(path)
- .unwrap();
- let reader = std::io::BufReader::new(file);
- bincode::deserialize_from::<_, FxHashMap>>(
- reader,
- )
- .inspect_err(|_| {
- tracing::warn!("failed to load existing cache, restarting");
- })
- .ok()
- })
- .unwrap_or_default();
- Self {
- client,
- state,
- path,
- }
- }
-
- pub fn cached_count(&self) -> usize {
- self.state.len()
- }
-
- pub fn cleared(mut self) -> Self {
- // delete file if exists and clear state
- self.state = Default::default();
- if let Some(path) = self.path.as_ref() {
- std::fs::remove_file(path).unwrap();
- }
- self
- }
-
- pub fn resolve(&mut self, ident: &Identifier) -> Vec {
- if let Some(data) = self.state.get(ident) {
- tracing::info!("skipping {}", ident);
- return data.to_owned();
- };
-
- tracing::info!("checking {}", ident);
-
- let mut count = 0;
- let _response = loop {
- let Some(response) = self.client.request(lsp_server::Request {
- id: 1.into(),
- method: "textDocument/prepareCallHierarchy".to_string(),
- params: serde_json::to_value(&lsp_types::CallHierarchyPrepareParams {
- text_document_position_params: lsp_types::TextDocumentPositionParams {
- position: ident.range.start,
- text_document: lsp_types::TextDocumentIdentifier {
- uri: lsp_types::Url::from_file_path(&ident.path).unwrap(),
- },
- },
- work_done_progress_params: lsp_types::WorkDoneProgressParams {
- work_done_token: Some(lsp_types::ProgressToken::String(
- "prepare".to_string(),
- )),
- },
- })
- .unwrap(),
- }) else {
- tracing::warn!("RA server shut down");
- return vec![];
- };
-
- if let Some(Some(value)) = response.result.as_ref().map(|r| r.as_array()) {
- if !value.is_empty() {
- break value.to_owned();
- }
- count += 1;
- }
-
- // textDocument/prepareCallHierarchy will sometimes return an empty array so try
- // at most 5 times
- if count > 5 {
- tracing::warn!("discovered isolated task {}", ident);
- break vec![];
- }
-
- std::thread::sleep(std::time::Duration::from_secs(1));
- };
-
- // callHierarchy/incomingCalls
- let Some(response) = self.client.request(lsp_server::Request {
- id: 1.into(),
- method: "callHierarchy/incomingCalls".to_string(),
- params: serde_json::to_value(lsp_types::CallHierarchyIncomingCallsParams {
- partial_result_params: lsp_types::PartialResultParams::default(),
- item: lsp_types::CallHierarchyItem {
- name: ident.name.to_owned(),
- kind: lsp_types::SymbolKind::FUNCTION,
- data: None,
- tags: None,
- detail: None,
- uri: lsp_types::Url::from_file_path(&ident.path).unwrap(),
- range: ident.range,
- selection_range: ident.range,
- },
- work_done_progress_params: lsp_types::WorkDoneProgressParams {
- work_done_token: Some(lsp_types::ProgressToken::String("prepare".to_string())),
- },
- })
- .unwrap(),
- }) else {
- tracing::warn!("RA server shut down");
- return vec![];
- };
-
- let links = if let Some(e) = response.error {
- tracing::warn!("unable to resolve {}: {:?}", ident, e);
- vec![]
- } else {
- let response: Result, _> =
- serde_path_to_error::deserialize(response.result.unwrap());
-
- response
- .unwrap()
- .into_iter()
- .map(|i| i.into())
- .collect::>()
- };
-
- tracing::debug!("links: {:?}", links);
-
- self.state.insert(ident.to_owned(), links.clone());
- links
- }
-}
diff --git a/turbopack/crates/turbo-static/src/identifier.rs b/turbopack/crates/turbo-static/src/identifier.rs
deleted file mode 100644
index 428629585d52..000000000000
--- a/turbopack/crates/turbo-static/src/identifier.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-use std::{fs, path::PathBuf};
-
-use lsp_types::{CallHierarchyIncomingCall, CallHierarchyItem, Range};
-use serde::{Deserialize, Serialize};
-
-/// A task that references another, with the range of the reference
-#[derive(Hash, PartialEq, Eq, Deserialize, Serialize, Clone, Debug)]
-pub struct IdentifierReference {
- pub identifier: Identifier,
- pub references: Vec, // the places where this identifier is used
-}
-
-/// identifies a task by its file, and range in the file
-#[derive(Hash, PartialEq, Eq, Deserialize, Serialize, Clone)]
-pub struct Identifier {
- pub path: String,
- // technically you can derive this from the name and range but it's easier to just store it
- pub name: String,
- // post_transform_name: Option,
- pub range: lsp_types::Range,
-}
-
-impl Identifier {
- /// check the span matches and the text matches
- ///
- /// `same_location` is used to check if the location of the identifier is
- /// the same as the other
- pub fn equals_ident(&self, other: &syn::Ident, match_location: bool) -> bool {
- *other == self.name
- && (!match_location
- || (self.range.start.line == other.span().start().line as u32
- && self.range.start.character == other.span().start().column as u32))
- }
-
- /// We cannot use `item.name` here in all cases as, during testing, the name
- /// does not always align with the exact text in the range.
- fn get_name(item: &CallHierarchyItem) -> String {
- // open file, find range inside, extract text
- let file = fs::read_to_string(item.uri.path()).unwrap();
- let start = item.selection_range.start;
- let end = item.selection_range.end;
- file.lines()
- .nth(start.line as usize)
- .unwrap()
- .chars()
- .skip(start.character as usize)
- .take(end.character as usize - start.character as usize)
- .collect()
- }
-}
-
-impl From<(PathBuf, syn::Ident)> for Identifier {
- fn from((path, ident): (PathBuf, syn::Ident)) -> Self {
- Self {
- path: path.display().to_string(),
- name: ident.to_string(),
- // post_transform_name: None,
- range: Range {
- start: lsp_types::Position {
- line: ident.span().start().line as u32 - 1,
- character: ident.span().start().column as u32,
- },
- end: lsp_types::Position {
- line: ident.span().end().line as u32 - 1,
- character: ident.span().end().column as u32,
- },
- },
- }
- }
-}
-
-impl From for IdentifierReference {
- fn from(item: CallHierarchyIncomingCall) -> Self {
- Self {
- identifier: Identifier {
- name: Identifier::get_name(&item.from),
- // post_transform_name: Some(item.from.name),
- path: item.from.uri.path().to_owned(),
- range: item.from.selection_range,
- },
- references: item.from_ranges,
- }
- }
-}
-
-impl std::fmt::Debug for Identifier {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- std::fmt::Display::fmt(self, f)
- }
-}
-
-impl std::fmt::Display for Identifier {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}:{}#{}", self.path, self.range.start.line, self.name,)
- }
-}
diff --git a/turbopack/crates/turbo-static/src/lsp_client.rs b/turbopack/crates/turbo-static/src/lsp_client.rs
deleted file mode 100644
index 25d29a7efd26..000000000000
--- a/turbopack/crates/turbo-static/src/lsp_client.rs
+++ /dev/null
@@ -1,161 +0,0 @@
-use std::{path::PathBuf, process, sync::mpsc};
-
-use lsp_server::Message;
-
-/// An LSP client for Rust Analyzer (RA) that launches it as a subprocess.
-pub struct RAClient {
- /// Handle to the client
- handle: process::Child,
- sender: Option>,
- receiver: Option>,
-}
-
-impl RAClient {
- /// Create a new LSP client for Rust Analyzer.
- pub fn new() -> Self {
- let stdin = process::Stdio::piped();
- let stdout = process::Stdio::piped();
- let stderr = process::Stdio::inherit();
-
- let child = process::Command::new("rust-analyzer")
- .stdin(stdin)
- .stdout(stdout)
- .stderr(stderr)
- // .env("RA_LOG", "info")
- .env("RUST_BACKTRACE", "1")
- .spawn()
- .expect("Failed to start RA LSP server");
- Self {
- handle: child,
- sender: None,
- receiver: None,
- }
- }
-
- pub fn start(&mut self, folders: &[PathBuf]) {
- let stdout = self.handle.stdout.take().unwrap();
- let mut stdin = self.handle.stdin.take().unwrap();
-
- let (writer_sender, writer_receiver) = mpsc::sync_channel::(0);
- _ = std::thread::spawn(move || {
- writer_receiver
- .into_iter()
- .try_for_each(|it| it.write(&mut stdin))
- });
-
- let (reader_sender, reader_receiver) = mpsc::sync_channel::(0);
- _ = std::thread::spawn(move || {
- let mut reader = std::io::BufReader::new(stdout);
- while let Ok(Some(msg)) = Message::read(&mut reader) {
- reader_sender
- .send(msg)
- .expect("receiver was dropped, failed to send a message");
- }
- });
-
- self.sender = Some(writer_sender);
- self.receiver = Some(reader_receiver);
-
- let workspace_paths = folders
- .iter()
- .map(|p| std::fs::canonicalize(p).unwrap())
- .map(|p| lsp_types::WorkspaceFolder {
- name: p.file_name().unwrap().to_string_lossy().to_string(),
- uri: lsp_types::Url::from_file_path(p).unwrap(),
- })
- .collect::>();
-
- _ = self.request(lsp_server::Request {
- id: 1.into(),
- method: "initialize".to_string(),
- params: serde_json::to_value(lsp_types::InitializeParams {
- workspace_folders: Some(workspace_paths),
- process_id: Some(std::process::id()),
- capabilities: lsp_types::ClientCapabilities {
- workspace: Some(lsp_types::WorkspaceClientCapabilities {
- workspace_folders: Some(true),
- ..Default::default()
- }),
- ..Default::default()
- },
- work_done_progress_params: lsp_types::WorkDoneProgressParams {
- work_done_token: Some(lsp_types::ProgressToken::String("prepare".to_string())),
- },
- // we use workspace_folders so root_path and root_uri can be
- // empty
- ..Default::default()
- })
- .unwrap(),
- });
-
- self.notify(lsp_server::Notification {
- method: "initialized".to_string(),
- params: serde_json::to_value(lsp_types::InitializedParams {}).unwrap(),
- });
- }
-
- /// Send an LSP request to the server. This returns an option
- /// in the case of an error such as the server being shut down
- /// from pressing `Ctrl+C`.
- pub fn request(&mut self, message: lsp_server::Request) -> Option {
- tracing::debug!("sending {:?}", message);
- self.sender
- .as_mut()
- .unwrap()
- .send(Message::Request(message))
- .ok()?;
-
- loop {
- match self.receiver.as_mut().unwrap().recv() {
- Ok(lsp_server::Message::Response(response)) => {
- tracing::debug!("received {:?}", response);
- return Some(response);
- }
- Ok(m) => tracing::trace!("unexpected message: {:?}", m),
- Err(_) => {
- tracing::trace!("error receiving message");
- return None;
- }
- }
- }
- }
-
- pub fn notify(&mut self, message: lsp_server::Notification) {
- self.sender
- .as_mut()
- .unwrap()
- .send(Message::Notification(message))
- .expect("failed to send message");
- }
-}
-
-impl Drop for RAClient {
- fn drop(&mut self) {
- if self.sender.is_some() {
- let Some(resp) = self.request(lsp_server::Request {
- id: 1.into(),
- method: "shutdown".to_string(),
- params: serde_json::to_value(()).unwrap(),
- }) else {
- return;
- };
-
- if resp.error.is_none() {
- tracing::info!("shutting down RA LSP server");
- self.notify(lsp_server::Notification {
- method: "exit".to_string(),
- params: serde_json::to_value(()).unwrap(),
- });
- self.handle
- .wait()
- .expect("failed to wait for RA LSP server");
- tracing::info!("shut down RA LSP server");
- } else {
- tracing::error!("failed to shutdown RA LSP server: {:#?}", resp);
- }
- }
-
- self.sender = None;
- self.receiver = None;
- }
-}
diff --git a/turbopack/crates/turbo-static/src/main.rs b/turbopack/crates/turbo-static/src/main.rs
deleted file mode 100644
index 73d8f8b43b4a..000000000000
--- a/turbopack/crates/turbo-static/src/main.rs
+++ /dev/null
@@ -1,302 +0,0 @@
-use std::{
- error::Error,
- fs,
- path::PathBuf,
- sync::{
- Arc,
- atomic::{AtomicBool, Ordering},
- },
-};
-
-use call_resolver::CallResolver;
-use clap::Parser;
-use identifier::{Identifier, IdentifierReference};
-use itertools::Itertools;
-use rustc_hash::{FxHashMap, FxHashSet};
-use syn::visit::Visit;
-use visitor::CallingStyleVisitor;
-
-use crate::visitor::CallingStyle;
-
-mod call_resolver;
-mod identifier;
-mod lsp_client;
-mod visitor;
-
-#[derive(Parser)]
-struct Opt {
- #[clap(required = true)]
- paths: Vec,
-
- /// reparse all files
- #[clap(long)]
- reparse: bool,
-
- /// reindex all files
- #[clap(long)]
- reindex: bool,
-}
-
-fn main() -> Result<(), Box> {
- tracing_subscriber::fmt::init();
- let opt = Opt::parse();
-
- let mut connection = lsp_client::RAClient::new();
- connection.start(&opt.paths);
-
- let call_resolver = CallResolver::new(&mut connection, Some("call_resolver.bincode".into()));
- let mut call_resolver = if opt.reindex {
- call_resolver.cleared()
- } else {
- call_resolver
- };
-
- let halt = Arc::new(AtomicBool::new(false));
- let halt_clone = halt.clone();
- ctrlc::set_handler({
- move || {
- halt_clone.store(true, Ordering::SeqCst);
- }
- })?;
-
- tracing::info!("getting tasks");
- let mut tasks = get_all_tasks(&opt.paths);
- let dep_tree = resolve_tasks(&mut tasks, &mut call_resolver, halt.clone());
- let concurrency = resolve_concurrency(&tasks, &dep_tree, halt.clone());
-
- write_dep_tree(&tasks, concurrency, std::path::Path::new("graph.cypherl"));
-
- if halt.load(Ordering::Relaxed) {
- tracing::info!("ctrl-c detected, exiting");
- }
-
- Ok(())
-}
-
-/// search the given folders recursively and attempt to find all tasks inside
-#[tracing::instrument(skip_all)]
-fn get_all_tasks(folders: &[PathBuf]) -> FxHashMap> {
- let mut out = FxHashMap::default();
-
- for folder in folders {
- let walker = ignore::Walk::new(folder);
- for entry in walker {
- let entry = entry.unwrap();
- let rs_file = if let Some(true) = entry.file_type().map(|t| t.is_file()) {
- let path = entry.path();
- let ext = path.extension().unwrap_or_default();
- if ext == "rs" {
- std::fs::canonicalize(path).unwrap()
- } else {
- continue;
- }
- } else {
- continue;
- };
-
- let file = fs::read_to_string(&rs_file).unwrap();
- let lines = file.lines();
- let mut occurrences = vec![];
-
- tracing::debug!("processing {}", rs_file.display());
-
- for ((_, line), (line_no, _)) in lines.enumerate().tuple_windows() {
- if line.contains("turbo_tasks::function") {
- tracing::debug!("found at {:?}:L{}", rs_file, line_no);
- occurrences.push(line_no + 1);
- }
- }
-
- if occurrences.is_empty() {
- continue;
- }
-
- // parse the file using syn and get the span of the functions
- let file = syn::parse_file(&file).unwrap();
- let occurrences_count = occurrences.len();
- let mut visitor = visitor::TaskVisitor::new();
- syn::visit::visit_file(&mut visitor, &file);
- if visitor.results.len() != occurrences_count {
- tracing::warn!(
- "file {:?} passed the heuristic with {:?} but the visitor found {:?}",
- rs_file,
- occurrences_count,
- visitor.results.len()
- );
- }
-
- out.extend(
- visitor
- .results
- .into_iter()
- .map(move |(ident, tags)| ((rs_file.clone(), ident).into(), tags)),
- )
- }
- }
-
- out
-}
-
-/// Given a list of tasks, get all the tasks that call that one
-fn resolve_tasks(
- tasks: &mut FxHashMap>,
- client: &mut CallResolver,
- halt: Arc,
-) -> FxHashMap> {
- tracing::info!(
- "found {} tasks, of which {} cached",
- tasks.len(),
- client.cached_count()
- );
-
- let mut unresolved = tasks.keys().cloned().collect::>();
- let mut resolved = FxHashMap::default();
-
- while let Some(top) = unresolved.iter().next().cloned() {
- unresolved.remove(&top);
-
- let callers = client.resolve(&top);
-
- // add all non-task callers to the unresolved list if they are not in the
- // resolved list
- for caller in callers.iter() {
- if !resolved.contains_key(&caller.identifier)
- && !unresolved.contains(&caller.identifier)
- {
- tracing::debug!("adding {} to unresolved", caller.identifier);
- unresolved.insert(caller.identifier.to_owned());
- }
- }
- resolved.insert(top.to_owned(), callers);
-
- if halt.load(Ordering::Relaxed) {
- break;
- }
- }
-
- resolved
-}
-
-/// given a map of tasks and functions that call it, produce a map of tasks and
-/// those tasks that it calls
-///
-/// returns a list of pairs with a task, the task that calls it, and the calling
-/// style
-fn resolve_concurrency(
- task_list: &FxHashMap>,
- dep_tree: &FxHashMap>, // pairs of tasks and call trees
- halt: Arc,
-) -> Vec<(Identifier, Identifier, CallingStyle)> {
- // println!("{:?}", dep_tree);
- // println!("{:#?}", task_list);
-
- let mut edges = vec![];
-
- for (ident, references) in dep_tree {
- for reference in references {
- #[allow(clippy::map_entry)] // This doesn't insert into dep_tree, so entry isn't useful
- if !dep_tree.contains_key(&reference.identifier) {
- // this is a task that is not in the task list
- // so we can't resolve it
- tracing::error!("missing task for {}: {}", ident, reference.identifier);
- for task in task_list.keys() {
- if task.name == reference.identifier.name {
- // we found a task that is not in the task list
- // so we can't resolve it
- tracing::trace!("- found {}", task);
- continue;
- }
- }
- continue;
- } else {
- // load the source file and get the calling style
- let target = IdentifierReference {
- identifier: ident.clone(),
- references: reference.references.clone(),
- };
- let mut visitor = CallingStyleVisitor::new(target);
- tracing::info!("looking for {} from {}", ident, reference.identifier);
- let file =
- syn::parse_file(&fs::read_to_string(&reference.identifier.path).unwrap())
- .unwrap();
- visitor.visit_file(&file);
-
- edges.push((
- ident.clone(),
- reference.identifier.clone(),
- visitor.result().unwrap_or(CallingStyle::Once),
- ));
- }
-
- if halt.load(Ordering::Relaxed) {
- break;
- }
- }
- }
-
- // parse each fn between parent and child and get the max calling style
-
- edges
-}
-
-/// Write the dep tree into the given file using cypher syntax
-fn write_dep_tree(
- task_list: &FxHashMap>,
- dep_tree: Vec<(Identifier, Identifier, CallingStyle)>,
- out: &std::path::Path,
-) {
- use std::io::Write;
-
- let mut node_ids = FxHashMap::default();
- let mut counter = 0;
-
- let mut file = std::fs::File::create(out).unwrap();
-
- let empty = vec![];
-
- // collect all tasks as well as all intermediate nodes
- // tasks come last to ensure the tags are preserved
- let node_list = dep_tree
- .iter()
- .flat_map(|(dest, src, _)| [(src, &empty), (dest, &empty)])
- .chain(task_list)
- .collect::>();
-
- for (ident, tags) in node_list {
- counter += 1;
-
- let label = if !task_list.contains_key(ident) {
- "Function"
- } else if tags.contains(&"fs".to_string()) || tags.contains(&"network".to_string()) {
- "ImpureTask"
- } else {
- "Task"
- };
-
- _ = writeln!(
- file,
- "CREATE (n_{}:{} {{name: '{}', file: '{}', line: {}, tags: [{}]}})",
- counter,
- label,
- ident.name,
- ident.path,
- ident.range.start.line,
- tags.iter().map(|t| format!("\"{t}\"")).join(",")
- );
- node_ids.insert(ident, counter);
- }
-
- for (dest, src, style) in &dep_tree {
- let style = match style {
- CallingStyle::Once => "ONCE",
- CallingStyle::ZeroOrOnce => "ZERO_OR_ONCE",
- CallingStyle::ZeroOrMore => "ZERO_OR_MORE",
- CallingStyle::OneOrMore => "ONE_OR_MORE",
- };
-
- let src_id = *node_ids.get(src).unwrap();
- let dst_id = *node_ids.get(dest).unwrap();
-
- _ = writeln!(file, "CREATE (n_{src_id})-[:{style}]->(n_{dst_id})",);
- }
-}
diff --git a/turbopack/crates/turbo-static/src/visitor.rs b/turbopack/crates/turbo-static/src/visitor.rs
deleted file mode 100644
index e6b260c9e65f..000000000000
--- a/turbopack/crates/turbo-static/src/visitor.rs
+++ /dev/null
@@ -1,274 +0,0 @@
-//! A visitor that traverses the AST and collects all functions or methods that
-//! are annotated with `#[turbo_tasks::function]`.
-
-use std::{collections::VecDeque, ops::Add};
-
-use lsp_types::Range;
-use syn::{Expr, Meta, visit::Visit};
-
-use crate::identifier::Identifier;
-
-pub struct TaskVisitor {
- /// the list of results as pairs of an identifier and its tags
- pub results: Vec<(syn::Ident, Vec)>,
-}
-
-impl TaskVisitor {
- pub fn new() -> Self {
- Self {
- results: Default::default(),
- }
- }
-}
-
-impl Visit<'_> for TaskVisitor {
- #[tracing::instrument(skip_all)]
- fn visit_item_fn(&mut self, i: &syn::ItemFn) {
- if let Some(tags) = extract_tags(i.attrs.iter()) {
- tracing::trace!("L{}: {}", i.sig.ident.span().start().line, i.sig.ident,);
- self.results.push((i.sig.ident.clone(), tags));
- }
- }
-
- #[tracing::instrument(skip_all)]
- fn visit_impl_item_fn(&mut self, i: &syn::ImplItemFn) {
- if let Some(tags) = extract_tags(i.attrs.iter()) {
- tracing::trace!("L{}: {}", i.sig.ident.span().start().line, i.sig.ident,);
- self.results.push((i.sig.ident.clone(), tags));
- }
- }
-}
-
-fn extract_tags<'a>(mut meta: impl Iterator- ) -> Option
> {
- meta.find_map(|a| match &a.meta {
- // path has two segments, turbo_tasks and function
- Meta::Path(path) if path.segments.len() == 2 => {
- let first = &path.segments[0];
- let second = &path.segments[1];
- (first.ident == "turbo_tasks" && second.ident == "function").then(std::vec::Vec::new)
- }
- Meta::List(list) if list.path.segments.len() == 2 => {
- let first = &list.path.segments[0];
- let second = &list.path.segments[1];
- if first.ident != "turbo_tasks" || second.ident != "function" {
- return None;
- }
-
- // collect ident tokens as args
- let tags: Vec<_> = list
- .tokens
- .clone()
- .into_iter()
- .filter_map(|t| {
- if let proc_macro2::TokenTree::Ident(ident) = t {
- Some(ident.to_string())
- } else {
- None
- }
- })
- .collect();
-
- Some(tags)
- }
- _ => {
- tracing::trace!("skipping unknown annotation");
- None
- }
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd)]
-pub enum CallingStyle {
- Once = 0b0010,
- ZeroOrOnce = 0b0011,
- ZeroOrMore = 0b0111,
- OneOrMore = 0b0110,
-}
-
-impl CallingStyle {
- fn bitset(self) -> u8 {
- self as u8
- }
-}
-
-impl Add for CallingStyle {
- type Output = Self;
-
- /// Add two calling styles together to determine the calling style of the
- /// target function within the source function.
- ///
- /// Consider it as a bitset over properties.
- /// - 0b000: Nothing
- /// - 0b001: Zero
- /// - 0b010: Once
- /// - 0b011: Zero Or Once
- /// - 0b100: More Than Once
- /// - 0b101: Zero Or More Than Once (?)
- /// - 0b110: Once Or More
- /// - 0b111: Zero Or More
- ///
- /// Note that zero is not a valid calling style.
- fn add(self, rhs: Self) -> Self {
- let left = self.bitset();
- let right = rhs.bitset();
-
- // we treat this as a bitset under addition
- #[allow(clippy::suspicious_arithmetic_impl)]
- match left | right {
- 0b0010 => CallingStyle::Once,
- 0b011 => CallingStyle::ZeroOrOnce,
- 0b0111 => CallingStyle::ZeroOrMore,
- 0b0110 => CallingStyle::OneOrMore,
- // the remaining 4 (null, zero, more than once, zero or more than once)
- // are unreachable because we don't detect 'zero' or 'more than once'
- _ => unreachable!(),
- }
- }
-}
-
-pub struct CallingStyleVisitor {
- pub reference: crate::IdentifierReference,
- state: VecDeque,
- halt: bool,
-}
-
-impl CallingStyleVisitor {
- /// Create a new visitor that will traverse the AST and determine the
- /// calling style of the target function within the source function.
- pub fn new(reference: crate::IdentifierReference) -> Self {
- Self {
- reference,
- state: Default::default(),
- halt: false,
- }
- }
-
- pub fn result(self) -> Option {
- self.state
- .into_iter()
- .map(|b| match b {
- CallingStyleVisitorState::Block => CallingStyle::Once,
- CallingStyleVisitorState::Loop => CallingStyle::ZeroOrMore,
- CallingStyleVisitorState::If => CallingStyle::ZeroOrOnce,
- CallingStyleVisitorState::Closure => CallingStyle::ZeroOrMore,
- })
- .reduce(|a, b| a + b)
- }
-}
-
-#[derive(Debug, Clone, Copy)]
-enum CallingStyleVisitorState {
- Block,
- Loop,
- If,
- Closure,
-}
-
-impl Visit<'_> for CallingStyleVisitor {
- fn visit_item_fn(&mut self, i: &'_ syn::ItemFn) {
- self.state.push_back(CallingStyleVisitorState::Block);
- syn::visit::visit_item_fn(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_impl_item_fn(&mut self, i: &'_ syn::ImplItemFn) {
- self.state.push_back(CallingStyleVisitorState::Block);
- syn::visit::visit_impl_item_fn(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_expr_loop(&mut self, i: &'_ syn::ExprLoop) {
- self.state.push_back(CallingStyleVisitorState::Loop);
- syn::visit::visit_expr_loop(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_expr_for_loop(&mut self, i: &'_ syn::ExprForLoop) {
- self.state.push_back(CallingStyleVisitorState::Loop);
- syn::visit::visit_expr_for_loop(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_expr_if(&mut self, i: &'_ syn::ExprIf) {
- self.state.push_back(CallingStyleVisitorState::If);
- syn::visit::visit_expr_if(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_expr_closure(&mut self, i: &'_ syn::ExprClosure) {
- self.state.push_back(CallingStyleVisitorState::Closure);
- syn::visit::visit_expr_closure(self, i);
- if !self.halt {
- self.state.pop_back();
- }
- }
-
- fn visit_expr_call(&mut self, i: &'_ syn::ExprCall) {
- syn::visit::visit_expr_call(self, i);
- if let Expr::Path(p) = i.func.as_ref()
- && let Some(last) = p.path.segments.last()
- && is_match(
- &self.reference.identifier,
- &last.ident,
- &self.reference.references,
- )
- {
- self.halt = true;
- }
- }
-
- // to validate this, we first check if the name is the same and then compare it
- // against any of the references we are holding
- fn visit_expr_method_call(&mut self, i: &'_ syn::ExprMethodCall) {
- if is_match(
- &self.reference.identifier,
- &i.method,
- &self.reference.references,
- ) {
- self.halt = true;
- }
-
- syn::visit::visit_expr_method_call(self, i);
- }
-}
-
-/// Check if some ident referenced by `check` is calling the `target` by
-/// looking it up in the list of known `ranges`.
-fn is_match(target: &Identifier, check: &syn::Ident, ranges: &[Range]) -> bool {
- if target.equals_ident(check, false) {
- let span = check.span();
- // syn is 1-indexed, range is not
- for reference in ranges {
- if reference.start.line != span.start().line as u32 - 1 {
- continue;
- }
-
- if reference.start.character != span.start().column as u32 {
- continue;
- }
-
- if reference.end.line != span.end().line as u32 - 1 {
- continue;
- }
-
- if reference.end.character != span.end().column as u32 {
- continue;
- }
-
- // match, just exit the visitor
- return true;
- }
- }
-
- false
-}
diff --git a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs
index 76270608f059..0fd40df481b1 100644
--- a/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs
+++ b/turbopack/crates/turbo-tasks-backend/src/backend/storage.rs
@@ -213,7 +213,6 @@ impl Storage {
if modified_count == 0 {
return None;
}
- let mut direct_snapshots: Vec<(TaskId, Box)> = Vec::new();
let mut modified = Vec::with_capacity(modified_count as usize);
{
let shard_guard = shard.read();
@@ -229,44 +228,26 @@ impl Storage {
// accompanied by modified flags (set_persistent_task_type calls
// track_modification), so any_modified() is sufficient.
if flags.any_modified() {
- debug_assert!(
- !key.is_transient(),
- "found a modified transient task: {:?}",
- shared_value.get().get_persistent_task_type()
- );
-
- if flags.any_modified_during_snapshot() {
- // Task was modified during snapshot mode, so a snapshot
- // copy must exist in the snapshots map (created by the
- // (true, true) case in track_modification_internal).
- // Remove the entry entirely so end_snapshot doesn't
- // double-process this task. When iterating in `next` we will
- // re-synchronize the task flags.
- let (_, snapshot) = self.snapshots.remove(key).expect(
- "task with modified_during_snapshot must have a snapshots entry",
+ if key.is_transient() {
+ debug_assert!(
+ false,
+ "found a modified transient task: {:?}",
+ shared_value.get().get_persistent_task_type()
);
- let snapshot = snapshot.expect(
- "snapshot entry for modified_during_snapshot task must contain a \
- value",
- );
- direct_snapshots.push((*key, snapshot));
- } else {
- modified.push(*key);
+ continue;
}
+
+ modified.push(*key);
}
}
// Safety: shard_guard must outlive the iterator.
drop(shard_guard);
}
- // Early return for shards with no entries at all
- if direct_snapshots.is_empty() && modified.is_empty() {
- return None;
- }
+ debug_assert!(!modified.is_empty());
Some(SnapshotShard {
shard_idx,
- direct_snapshots,
modified,
storage: self,
process,
@@ -568,7 +549,6 @@ impl Drop for SnapshotGuard<'_> {
pub struct SnapshotShard<'l, P> {
shard_idx: usize,
- direct_snapshots: Vec<(TaskId, Box)>,
modified: Vec,
storage: &'l Storage,
process: &'l P,
@@ -606,16 +586,27 @@ where
type Item = SnapshotItem;
fn next(&mut self) -> Option {
- // direct_snapshots: these tasks had a snapshot copy created by
- // track_modification. We encode from the owned snapshot copy,
- // clear the stale modified flags, and promote any _during_snapshot
- // flags so the task stays dirty for the next cycle.
- if let Some((task_id, snapshot)) = self.shard.direct_snapshots.pop() {
- let item = (self.shard.process)(task_id, &snapshot, &mut self.buffer);
- // Clear pre-snapshot flags. Since we removed this task's entry from the
- // snapshots map in take_snapshot, end_snapshot won't see it, so we must
- // promote here.
+ if let Some(task_id) = self.shard.modified.pop() {
let mut inner = self.shard.storage.map.get_mut(&task_id).unwrap();
+ // If the task was re-modified during snapshot, the snapshots map may
+ // hold a pre-modification copy we must serialize instead of the live
+ // data. Remove the entry so end_snapshot doesn't double-promote it;
+ // we promote manually below.
+ let item = if inner.flags.any_modified_during_snapshot() {
+ match self.shard.storage.snapshots.remove(&task_id) {
+ Some((_, Some(snapshot))) => {
+ (self.shard.process)(task_id, &snapshot, &mut self.buffer)
+ }
+ Some((_, None)) | None => {
+ (self.shard.process)(task_id, &inner, &mut self.buffer)
+ }
+ }
+ } else {
+ (self.shard.process)(task_id, &inner, &mut self.buffer)
+ };
+ // Clear the modified flags that were captured into the snapshot copy,
+ // then promote modified_during_snapshot → modified so the task stays
+ // dirty for the next snapshot cycle.
inner.flags.set_data_modified(false);
inner.flags.set_meta_modified(false);
inner.flags.set_new_task(false);
@@ -624,45 +615,6 @@ where
.promote_during_snapshot_flags(&mut inner, self.shard.shard_idx);
return Some(item);
}
- // modified tasks: acquire a write lock to encode and clear flags in one pass.
- if let Some(task_id) = self.shard.modified.pop() {
- let mut inner = self.shard.storage.map.get_mut(&task_id).unwrap();
- if !inner.flags.any_modified_during_snapshot() {
- let item = (self.shard.process)(task_id, &inner, &mut self.buffer);
- inner.flags.set_data_modified(false);
- inner.flags.set_meta_modified(false);
- inner.flags.set_new_task(false);
- return Some(item);
- } else {
- // Task was modified again during snapshot mode. A snapshot copy was
- // created in track_modification_internal. Remove it and encode it.
- // end_snapshot must not also process it, so we take it out of the map.
- // snapshots is a separate DashMap from map, so holding `inner` across
- // the remove and encode is safe — no lock ordering issue.
- let snapshot = self
- .shard
- .storage
- .snapshots
- .remove(&task_id)
- .expect("The snapshot bit was set, so it must be in Snapshot state")
- .1
- .expect(
- "snapshot entry for modified_during_snapshot task must contain a value",
- );
-
- let item = (self.shard.process)(task_id, &snapshot, &mut self.buffer);
- // Clear the modified flags that were captured into the snapshot copy,
- // then promote modified_during_snapshot → modified so the task stays
- // dirty for the next snapshot cycle.
- inner.flags.set_data_modified(false);
- inner.flags.set_meta_modified(false);
- inner.flags.set_new_task(false);
- self.shard
- .storage
- .promote_during_snapshot_flags(&mut inner, self.shard.shard_idx);
- return Some(item);
- }
- }
None
}
}
@@ -704,20 +656,22 @@ mod tests {
}
/// Regression test: a task modified before a snapshot and then modified *again* during
- /// snapshot iteration must not trigger `debug_assert!(!inner.flags.any_modified())` in
- /// `SnapshotShardIter::next`.
+ /// snapshot iteration must serialize the pre-snapshot state and carry the during-snapshot
+ /// modification forward to the next cycle.
///
/// Sequence of events:
/// 1. Task is modified (data_modified = true) → added to shard_modified_counts.
/// 2. `start_snapshot` puts us in snapshot mode.
- /// 3. `take_snapshot` scans the shard: task has `any_modified()=true` and
- /// `any_modified_during_snapshot()=false` → task goes into the `modified` list.
- /// 4. **Between scan and iteration**: `track_modification` is called on the task again. This is
- /// the `(true, true)` branch: already modified AND in snapshot mode. A snapshot copy of the
- /// pre-snapshot state is created (carrying the modified bits) and stored in `snapshots`.
- /// 5. `SnapshotShardIter::next` processes the task from the `modified` list, finds
- /// `any_modified_during_snapshot()=true`, clears the live modified flags (which were
- /// captured into the snapshot), then asserts `!any_modified()` before promoting.
+ /// 3. `take_snapshot` scans the shard: task has `any_modified()=true` → goes into the
+ /// `modified` list.
+ /// 4. **Between scan and iteration**: `track_modification` is called on the same category. This
+ /// is the `(true, true)` branch: already modified AND in snapshot mode. A snapshot copy of
+ /// the pre-second-modification state is stored in `snapshots` as `Some(copy)`, and
+ /// `data_modified_during_snapshot` is set.
+ /// 5. `SnapshotShardIter::next` processes the task from the `modified` list, detects
+ /// `any_modified_during_snapshot()=true`, finds the `Some(copy)` in `snapshots`, encodes the
+ /// pre-snapshot copy, clears the live modified flags, removes the snapshots entry, and
+ /// promotes `data_modified_during_snapshot → data_modified` for the next cycle.
// `end_snapshot` uses `parallel::for_each` which calls `block_in_place` internally,
// requiring a multi-threaded Tokio runtime.
#[tokio::test(flavor = "multi_thread")]
@@ -751,8 +705,8 @@ mod tests {
assert!(guard.flags.data_modified_during_snapshot())
}
- // Step 5: consume the iterator. The iterator clears the live modified flags
- // before the assert, encodes the snapshot copy, and promotes
+ // Step 5: consume the iterator. The iterator encodes from the pre-snapshot copy,
+ // clears the live modified flags, removes the snapshots entry, and promotes
// `data_modified_during_snapshot → data_modified` for the next cycle.
let items: Vec<_> = shards
.into_iter()
@@ -765,7 +719,7 @@ mod tests {
{
let guard = storage.access_mut(task_id);
- // Ending the snapshot should have promoted modified_during_snapshot → modified.
+ // The iterator should have promoted modified_during_snapshot → modified.
assert!(guard.flags.data_modified());
}
@@ -777,4 +731,73 @@ mod tests {
"shard_modified_counts must be non-zero after promoting modified_during_snapshot"
);
}
+
+ /// Regression test for the `(true, false)` during-snapshot case: a task modified in one
+ /// category before a snapshot, then modified in a *different* category during snapshot
+ /// iteration, must not panic and must carry both modifications forward correctly.
+ ///
+ /// Sequence of events:
+ /// 1. Task meta is modified (meta_modified = true).
+ /// 2. `start_snapshot` puts us in snapshot mode.
+ /// 3. `take_snapshot` scans the shard: task goes into the `modified` list.
+ /// 4. Task data is modified during snapshot → `(true, false)` branch: data was not previously
+ /// modified, so `snapshots` gets a `None` entry and `data_modified_during_snapshot` is set.
+ /// 5. `SnapshotShardIter::next` processes the task: finds `any_modified_during_snapshot()`,
+ /// sees `None` in snapshots, encodes from live data (correct — live data for the
+ /// unmodified-before-snapshot category is still the pre-snapshot state), clears pre-snapshot
+ /// flags, and promotes `data_modified_during_snapshot → data_modified`.
+ #[tokio::test(flavor = "multi_thread")]
+ async fn modify_different_category_during_snapshot() {
+ let storage = Storage::new(2, true);
+ let task_id = non_transient_task(1);
+
+ // Step 1: modify meta only, outside snapshot mode.
+ {
+ let mut guard = storage.access_mut(task_id);
+ guard.track_modification(SpecificTaskDataCategory::Meta, "test");
+ assert!(guard.flags.meta_modified());
+ assert!(!guard.flags.data_modified());
+ }
+
+ // Step 2: enter snapshot mode.
+ let (snapshot_guard, has_modifications) = storage.start_snapshot();
+ assert!(has_modifications);
+
+ // Step 3: take_snapshot — task goes into modified list (meta_modified = true).
+ let shards = storage.take_snapshot(snapshot_guard, &dummy_process);
+
+ // Step 4: modify data during snapshot. The `(true, false)` branch fires:
+ // data was not previously modified, so snapshots gets a None entry.
+ {
+ let mut guard = storage.access_mut(task_id);
+ guard.track_modification(SpecificTaskDataCategory::Data, "test");
+ assert!(guard.flags.data_modified_during_snapshot());
+ assert!(!guard.flags.meta_modified_during_snapshot());
+ }
+
+ // Step 5: consume the iterator — must not panic.
+ let items: Vec<_> = shards
+ .into_iter()
+ .flat_map(|shard| shard.into_iter())
+ .collect();
+
+ assert_eq!(items.len(), 1);
+ assert_eq!(items[0].task_id, task_id);
+
+ {
+ let guard = storage.access_mut(task_id);
+ // meta_modified was cleared by the iterator (it was the pre-snapshot flag).
+ assert!(!guard.flags.meta_modified());
+ // data_modified_during_snapshot was promoted to data_modified.
+ assert!(guard.flags.data_modified());
+ assert!(!guard.flags.data_modified_during_snapshot());
+ }
+
+ // Next snapshot cycle must pick up the promoted data_modified.
+ let (_guard2, has_modifications) = storage.start_snapshot();
+ assert!(
+ has_modifications,
+ "shard_modified_counts must be non-zero after promoting data_modified_during_snapshot"
+ );
+ }
}