diff --git a/lerna.json b/lerna.json index 6e2bacdd99839..3b3110817de4b 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "14.0.3-canary.7" + "version": "14.0.3-canary.8" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index 89c5bc932dff2..21223390a31cb 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 7693fcaac3a6d..234b2f3a70236 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/building-your-application/configuring/eslint#eslint-config", "dependencies": { - "@next/eslint-plugin-next": "14.0.3-canary.7", + "@next/eslint-plugin-next": "14.0.3-canary.8", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0", "eslint-import-resolver-node": "^0.3.6", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 34244f3a64f92..6f006403585fb 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "license": "MIT", diff --git a/packages/font/package.json b/packages/font/package.json index e62b66e81a432..30c3be44542e5 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,6 +1,6 @@ { "name": "@next/font", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index f9f33874a7d44..6c7f8484a12ec 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 82d28298ccd15..089d3209570bd 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index b7a298ce63bae..63151c4f30794 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 7b1ea61900a36..272059f0e00ad 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index c6eb1961ceb81..25ff0894b6abe 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 2c459be4cb08b..7102b276b5944 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 6d6c890ae21e7..391282f5f7059 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/crates/next-core/src/next_config.rs b/packages/next-swc/crates/next-core/src/next_config.rs index eecbba2a34b9b..15e43a2971a0b 100644 --- a/packages/next-swc/crates/next-core/src/next_config.rs +++ b/packages/next-swc/crates/next-core/src/next_config.rs @@ -442,7 +442,7 @@ pub struct ExperimentalConfig { pub optimize_css: Option, pub next_script_workers: Option, pub web_vitals_attribution: Option>, - pub server_actions: Option, + pub server_actions: Option, pub sri: Option, // --- @@ -511,7 +511,18 @@ pub struct SubResourceIntegrity { pub algorithm: Option, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TraceRawVcs)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TraceRawVcs)] +#[serde(untagged)] +pub enum ServerActionsOrLegacyBool { + /// The current way to configure server actions sub behaviors. + ServerActionsConfig(ServerActions), + + /// The legacy way to disable server actions. This is no longer used, server + /// actions is always enabled. + LegacyBool(bool), +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TraceRawVcs)] #[serde(rename_all = "camelCase")] pub struct ServerActions { /// Allows adjusting body parser size limit for server actions. diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 241d0a382d73b..4127d06dafb17 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "private": true, "scripts": { "clean": "node ../../scripts/rm.mjs native", diff --git a/packages/next/package.json b/packages/next/package.json index 6e8462a87ddfb..4f6da4111e81b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -92,7 +92,7 @@ ] }, "dependencies": { - "@next/env": "14.0.3-canary.7", + "@next/env": "14.0.3-canary.8", "@swc/helpers": "0.5.2", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001406", @@ -146,11 +146,11 @@ "@mswjs/interceptors": "0.23.0", "@napi-rs/cli": "2.16.2", "@napi-rs/triples": "1.1.0", - "@next/polyfill-module": "14.0.3-canary.7", - "@next/polyfill-nomodule": "14.0.3-canary.7", - "@next/react-dev-overlay": "14.0.3-canary.7", - "@next/react-refresh-utils": "14.0.3-canary.7", - "@next/swc": "14.0.3-canary.7", + "@next/polyfill-module": "14.0.3-canary.8", + "@next/polyfill-nomodule": "14.0.3-canary.8", + "@next/react-dev-overlay": "14.0.3-canary.8", + "@next/react-refresh-utils": "14.0.3-canary.8", + "@next/swc": "14.0.3-canary.8", "@opentelemetry/api": "1.6.0", "@playwright/test": "^1.35.1", "@taskr/clear": "1.1.0", diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 13c6811c20017..d66dc65eb2f13 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -35,7 +35,7 @@ import { PrefetchKind, } from './router-reducer/router-reducer-types' import type { - PushRef, + AppRouterState, ReducerActions, RouterChangeByServerResponse, RouterNavigate, @@ -49,6 +49,7 @@ import { import { useReducerWithReduxDevtools, useUnwrapState, + type ReduxDevtoolsSyncFn, } from './use-reducer-with-devtools' import { ErrorBoundary } from './error-boundary' import { createInitialRouterState } from './router-reducer/create-initial-router-state' @@ -110,17 +111,14 @@ function isExternalURL(url: URL) { } function HistoryUpdater({ - tree, - pushRef, - canonicalUrl, + appRouterState, sync, }: { - tree: FlightRouterState - pushRef: PushRef - canonicalUrl: string - sync: () => void + appRouterState: AppRouterState + sync: ReduxDevtoolsSyncFn }) { useInsertionEffect(() => { + const { tree, pushRef, canonicalUrl } = appRouterState const historyState = { ...(process.env.__NEXT_WINDOW_HISTORY_SUPPORT && pushRef.preserveCustomHistoryState @@ -148,8 +146,8 @@ function HistoryUpdater({ originalReplaceState(historyState, '', canonicalUrl) } } - sync() - }, [tree, pushRef, canonicalUrl, sync]) + sync(appRouterState) + }, [appRouterState, sync]) return null } @@ -589,9 +587,7 @@ function Router({ return ( <> diff --git a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx index ea809ab10ce2f..01e7f8dda40a7 100644 --- a/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx +++ b/packages/next/src/client/components/react-dev-overlay/hot-reloader-client.tsx @@ -338,16 +338,14 @@ function processMessage( }) ) - if (!process.env.TURBOPACK) { - const isHotUpdate = - obj.action !== HMR_ACTIONS_SENT_TO_BROWSER.SYNC && - (!window.__NEXT_DATA__ || window.__NEXT_DATA__.page !== '/_error') && - isUpdateAvailable() - - // Attempt to apply hot updates or reload. - if (isHotUpdate) { - handleHotUpdate() - } + const isHotUpdate = + obj.action !== HMR_ACTIONS_SENT_TO_BROWSER.SYNC && + (!window.__NEXT_DATA__ || window.__NEXT_DATA__.page !== '/_error') && + isUpdateAvailable() + + // Attempt to apply hot updates or reload. + if (isHotUpdate) { + handleHotUpdate() } return } @@ -370,6 +368,13 @@ function processMessage( dispatcher.onRefresh() }) + if (process.env.__NEXT_TEST_MODE) { + if (self.__NEXT_HMR_CB) { + self.__NEXT_HMR_CB() + self.__NEXT_HMR_CB = null + } + } + return } case HMR_ACTIONS_SENT_TO_BROWSER.RELOAD_PAGE: { @@ -444,16 +449,6 @@ export default function HotReload({ } }, [dispatch]) - useEffect(() => { - if (process.env.__NEXT_TEST_MODE) { - if (self.__NEXT_HMR_CB) { - self.__NEXT_HMR_CB() - self.__NEXT_HMR_CB = null - } - } - // currentHmrObj will change when ACTION_REFRESH is dispatched. - }, [state.currentHmrObj]) - const handleOnUnhandledError = useCallback((error: Error): void => { // Component stack is added to the error in use-error-handler in case there was a hydration errror const componentStack = (error as any)._componentStack diff --git a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts index e4aa340a3450e..4536a263277e4 100644 --- a/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts +++ b/packages/next/src/client/components/react-dev-overlay/internal/error-overlay-reducer.ts @@ -12,7 +12,6 @@ export const ACTION_UNHANDLED_REJECTION = 'unhandled-rejection' export const ACTION_VERSION_INFO = 'version-info' export const INITIAL_OVERLAY_STATE: OverlayState = { nextId: 1, - currentHmrObj: {}, buildError: null, errors: [], notFound: false, @@ -61,7 +60,6 @@ export type FastRefreshState = } export interface OverlayState { - currentHmrObj: {} nextId: number buildError: string | null errors: SupportedErrorEvent[] @@ -111,9 +109,6 @@ export const errorOverlayReducer: React.Reducer< case ACTION_REFRESH: { return { ...state, - // Create a new hmrObj to track when a HMR was applied, this ensures the HMR callback `useEffect` will be called. - // Uses an object as the identity is unique, so anytime it changes it will trigger a new run of the effect. - currentHmrObj: {}, buildError: null, errors: // Errors can come in during updates. In this case, UNHANDLED_ERROR diff --git a/packages/next/src/client/components/use-reducer-with-devtools.ts b/packages/next/src/client/components/use-reducer-with-devtools.ts index 1db2769684db3..dd75194c63a3b 100644 --- a/packages/next/src/client/components/use-reducer-with-devtools.ts +++ b/packages/next/src/client/components/use-reducer-with-devtools.ts @@ -9,6 +9,8 @@ import { } from './router-reducer/router-reducer-types' import { ActionQueueContext } from '../../shared/lib/router/action-queue' +export type ReduxDevtoolsSyncFn = (state: AppRouterState) => void + function normalizeRouterState(val: any): any { if (val instanceof Map) { const obj: { [key: string]: any } = {} @@ -86,13 +88,13 @@ export function useUnwrapState(state: ReducerState): AppRouterState { function useReducerWithReduxDevtoolsNoop( initialState: AppRouterState -): [ReducerState, Dispatch, () => void] { +): [ReducerState, Dispatch, ReduxDevtoolsSyncFn] { return [initialState, () => {}, () => {}] } function useReducerWithReduxDevtoolsImpl( initialState: AppRouterState -): [ReducerState, Dispatch, () => void] { +): [ReducerState, Dispatch, ReduxDevtoolsSyncFn] { const [state, setState] = React.useState(initialState) const actionQueue = useContext(ActionQueueContext) @@ -149,14 +151,20 @@ function useReducerWithReduxDevtoolsImpl( [actionQueue, initialState] ) - const sync = useCallback(() => { + // Sync is called after a state update in the HistoryUpdater, + // for debugging purposes. Since the reducer state may be a Promise, + // we let the app router use() it and sync on the resolved value if + // something changed. + // Using the `state` here would be referentially unstable and cause + // undesirable re-renders and history updates. + const sync = useCallback((resolvedState) => { if (devtoolsConnectionRef.current) { devtoolsConnectionRef.current.send( { type: 'RENDER_SYNC' }, - normalizeRouterState(state) + normalizeRouterState(resolvedState) ) } - }, [state]) + }, []) return [state, dispatch, sync] } diff --git a/packages/next/src/export/routes/app-page.ts b/packages/next/src/export/routes/app-page.ts index df3b97c3db47d..2806579353ba0 100644 --- a/packages/next/src/export/routes/app-page.ts +++ b/packages/next/src/export/routes/app-page.ts @@ -15,6 +15,7 @@ import { import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error' import { NEXT_CACHE_TAGS_HEADER, + NEXT_META_SUFFIX, RSC_PREFETCH_SUFFIX, RSC_SUFFIX, } from '../../lib/constants' @@ -206,7 +207,7 @@ export async function exportAppPage( await fileWriter( ExportedAppPageFiles.META, - htmlFilepath.replace(/\.html$/, '.meta'), + htmlFilepath.replace(/\.html$/, NEXT_META_SUFFIX), JSON.stringify(meta, null, 2) ) diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 9508a76418136..bbb2eeba645bb 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -4,7 +4,11 @@ import type { AppRouteRouteHandlerContext } from '../../server/future/route-modu import type { IncrementalCache } from '../../server/lib/incremental-cache' import { join } from 'path' -import { NEXT_CACHE_TAGS_HEADER } from '../../lib/constants' +import { + NEXT_BODY_SUFFIX, + NEXT_CACHE_TAGS_HEADER, + NEXT_META_SUFFIX, +} from '../../lib/constants' import { NodeNextRequest } from '../../server/base-http/node' import { RouteModuleLoader } from '../../server/future/helpers/module-loader/route-module-loader' import { @@ -104,7 +108,7 @@ export async function exportAppRoute( const body = Buffer.from(await blob.arrayBuffer()) await fileWriter( ExportedAppRouteFiles.BODY, - htmlFilepath.replace(/\.html$/, '.body'), + htmlFilepath.replace(/\.html$/, NEXT_BODY_SUFFIX), body, 'utf8' ) @@ -113,7 +117,7 @@ export async function exportAppRoute( const meta = { status: response.status, headers } await fileWriter( ExportedAppRouteFiles.META, - htmlFilepath.replace(/\.html$/, '.meta'), + htmlFilepath.replace(/\.html$/, NEXT_META_SUFFIX), JSON.stringify(meta) ) diff --git a/packages/next/src/export/routes/pages.ts b/packages/next/src/export/routes/pages.ts index 20f542d22ece3..0cedd3594696f 100644 --- a/packages/next/src/export/routes/pages.ts +++ b/packages/next/src/export/routes/pages.ts @@ -11,7 +11,10 @@ import type { MockedResponse, } from '../../server/lib/mock-request' import { isInAmpMode } from '../../shared/lib/amp-mode' -import { SERVER_PROPS_EXPORT_ERROR } from '../../lib/constants' +import { + NEXT_DATA_SUFFIX, + SERVER_PROPS_EXPORT_ERROR, +} from '../../lib/constants' import { NEXT_DYNAMIC_NO_SSR_CODE } from '../../shared/lib/lazy-dynamic/no-ssr-error' import AmpHtmlValidator from 'next/dist/compiled/amphtml-validator' import { FileType, fileExists } from '../../lib/file-exists' @@ -187,7 +190,7 @@ export async function exportPages( if (metadata.pageData) { const dataFile = join( pagesDataDir, - htmlFilename.replace(/\.html$/, '.json') + htmlFilename.replace(/\.html$/, NEXT_DATA_SUFFIX) ) await fileWriter( diff --git a/packages/next/src/lib/constants.ts b/packages/next/src/lib/constants.ts index 57d0632b772f2..10e1793f5d952 100644 --- a/packages/next/src/lib/constants.ts +++ b/packages/next/src/lib/constants.ts @@ -10,6 +10,9 @@ export const NEXT_DID_POSTPONE_HEADER = 'x-nextjs-postponed' export const RSC_PREFETCH_SUFFIX = '.prefetch.rsc' export const RSC_SUFFIX = '.rsc' +export const NEXT_DATA_SUFFIX = '.json' +export const NEXT_META_SUFFIX = '.meta' +export const NEXT_BODY_SUFFIX = '.body' export const NEXT_CACHE_TAGS_HEADER = 'x-next-cache-tags' export const NEXT_CACHE_SOFT_TAGS_HEADER = 'x-next-cache-soft-tags' diff --git a/packages/next/src/lib/turbopack-warning.ts b/packages/next/src/lib/turbopack-warning.ts index 10b99bab6dc6c..cbc0bbe46af6b 100644 --- a/packages/next/src/lib/turbopack-warning.ts +++ b/packages/next/src/lib/turbopack-warning.ts @@ -253,7 +253,17 @@ export async function validateTurboNextConfig({ } let isSupported = - supportedKeys.some((supportedKey) => key.startsWith(supportedKey)) || + supportedKeys.some( + (supportedKey) => + // Either the key matches (or is a more specific subkey) of + // supportedKey, or the key is the path to a specific subkey. + // | key | supportedKey | + // |---------|--------------| + // | foo | foo | + // | foo.bar | foo | + // | foo | foo.bar | + key.startsWith(supportedKey) || supportedKey.startsWith(`${key}.`) + ) || getDeepValue(rawNextConfig, key) === getDeepValue(defaultConfig, key) if (!isSupported) { unsupportedConfig.push(key) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index d987d92f586fc..e1d8e7960f67c 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -2453,10 +2453,9 @@ export default abstract class Server { ssgCacheKey, async ( hasResolved, - previousCacheEntry + previousCacheEntry, + isRevalidating ): Promise => { - // If this is a resume request, get the postponed. - const postponed = resumed ? resumed.postponed : undefined const isProduction = !this.renderOpts.dev const didRespond = hasResolved || res.sent @@ -2494,6 +2493,12 @@ export default abstract class Server { isOnDemandRevalidate = true } + // Only requests that aren't revalidating can be resumed. + const postponed = + !isOnDemandRevalidate && !isRevalidating && resumed + ? resumed.postponed + : undefined + // only allow on-demand revalidate for fallback: true/blocking // or for prerendered fallback: false paths if ( @@ -2603,7 +2608,7 @@ export default abstract class Server { { routeKind: routeModule?.definition.kind, incrementalCache, - isOnDemandRevalidate: isOnDemandRevalidate, + isOnDemandRevalidate, isPrefetch: req.headers.purpose === 'prefetch', } ) diff --git a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts index 896795ec08737..e0361c5c23075 100644 --- a/packages/next/src/server/lib/incremental-cache/fetch-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/fetch-cache.ts @@ -142,19 +142,13 @@ export default class FetchCache implements CacheHandler { } } - public async get( - key: string, - ctx: { - tags?: string[] - softTags?: string[] - fetchCache?: boolean - fetchUrl?: string - fetchIdx?: number - } - ) { - const { tags, softTags, fetchCache, fetchIdx, fetchUrl } = ctx + public async get(...args: Parameters) { + const [key, ctx = {}] = args + const { tags, softTags, kindHint, fetchIdx, fetchUrl } = ctx - if (!fetchCache) return null + if (kindHint !== 'fetch') { + return null + } if (Date.now() < rateLimitedUntil) { if (this.debug) { @@ -262,21 +256,9 @@ export default class FetchCache implements CacheHandler { return data || null } - public async set( - key: string, - data: CacheHandlerValue['value'], - { - fetchCache, - fetchIdx, - fetchUrl, - tags, - }: { - tags?: string[] - fetchCache?: boolean - fetchUrl?: string - fetchIdx?: number - } - ) { + public async set(...args: Parameters) { + const [key, data, ctx] = args + const { fetchCache, fetchIdx, fetchUrl, tags } = ctx if (!fetchCache) return if (Date.now() < rateLimitedUntil) { diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 33ab3d3a01d65..a742279ea2c00 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -1,15 +1,14 @@ import type { RouteMetadata } from '../../../export/routes/types' import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' import type { CacheFs } from '../../../shared/lib/utils' -import type { - CachedFetchValue, - IncrementalCacheKindHint, -} from '../../response-cache' +import type { CachedFetchValue } from '../../response-cache' import LRUCache from 'next/dist/compiled/lru-cache' import path from '../../../shared/lib/isomorphic/path' import { NEXT_CACHE_TAGS_HEADER, + NEXT_DATA_SUFFIX, + NEXT_META_SUFFIX, RSC_PREFETCH_SUFFIX, RSC_SUFFIX, } from '../../../lib/constants' @@ -118,18 +117,9 @@ export default class FileSystemCache implements CacheHandler { } } - public async get( - key: string, - { - tags, - softTags, - kindHint, - }: { - tags?: string[] - softTags?: string[] - kindHint?: IncrementalCacheKindHint - } = {} - ) { + public async get(...args: Parameters) { + const [key, ctx = {}] = args + const { tags, softTags, kindHint } = ctx let data = memoryCache?.get(key) // let's check the disk for seed data @@ -140,7 +130,10 @@ export default class FileSystemCache implements CacheHandler { const { mtime } = await this.fs.stat(filePath) const meta = JSON.parse( - await this.fs.readFile(filePath.replace(/\.body$/, '.meta'), 'utf8') + await this.fs.readFile( + filePath.replace(/\.body$/, NEXT_META_SUFFIX), + 'utf8' + ) ) const cacheEntry: CacheHandlerValue = { @@ -204,7 +197,7 @@ export default class FileSystemCache implements CacheHandler { ) : JSON.parse( await this.fs.readFile( - this.getFilePath(`${key}.json`, 'pages'), + this.getFilePath(`${key}${NEXT_DATA_SUFFIX}`, 'pages'), 'utf8' ) ) @@ -215,7 +208,7 @@ export default class FileSystemCache implements CacheHandler { try { meta = JSON.parse( await this.fs.readFile( - filePath.replace(/\.html$/, '.meta'), + filePath.replace(/\.html$/, NEXT_META_SUFFIX), 'utf8' ) ) @@ -297,13 +290,8 @@ export default class FileSystemCache implements CacheHandler { return data ?? null } - public async set( - key: string, - data: CacheHandlerValue['value'], - ctx: { - tags?: string[] - } - ) { + public async set(...args: Parameters) { + const [key, data, ctx] = args memoryCache?.set(key, { value: data, lastModified: Date.now(), @@ -322,7 +310,7 @@ export default class FileSystemCache implements CacheHandler { } await this.fs.writeFile( - filePath.replace(/\.body$/, '.meta'), + filePath.replace(/\.body$/, NEXT_META_SUFFIX), JSON.stringify(meta, null, 2) ) return @@ -339,7 +327,13 @@ export default class FileSystemCache implements CacheHandler { await this.fs.writeFile( this.getFilePath( - `${key}.${isAppPath ? 'rsc' : 'json'}`, + `${key}${ + isAppPath + ? this.experimental.ppr + ? RSC_PREFETCH_SUFFIX + : RSC_SUFFIX + : NEXT_DATA_SUFFIX + }`, isAppPath ? 'app' : 'pages' ), isAppPath ? data.pageData : JSON.stringify(data.pageData) @@ -353,7 +347,7 @@ export default class FileSystemCache implements CacheHandler { } await this.fs.writeFile( - htmlPath.replace(/\.html$/, '.meta'), + htmlPath.replace(/\.html$/, NEXT_META_SUFFIX), JSON.stringify(meta) ) } diff --git a/packages/next/src/server/response-cache/index.ts b/packages/next/src/server/response-cache/index.ts index 76fa0ddad245f..5b0d30a5eac31 100644 --- a/packages/next/src/server/response-cache/index.ts +++ b/packages/next/src/server/response-cache/index.ts @@ -8,16 +8,16 @@ import type { } from './types' import { RouteKind } from '../future/route-kind' -import RenderResult from '../render-result' import { Batcher } from '../../lib/batcher' import { scheduleOnNextTick } from '../../lib/scheduler' +import { fromResponseCacheEntry, toResponseCacheEntry } from './utils' export * from './types' export default class ResponseCache implements ResponseCacheBase { private readonly batcher = Batcher.create< { key: string; isOnDemandRevalidate: boolean }, - ResponseCacheEntry | null, + IncrementalCacheItem | null, string >({ // Ensure on-demand revalidate doesn't block normal requests, it should be @@ -32,7 +32,7 @@ export default class ResponseCache implements ResponseCacheBase { private previousCacheItem?: { key: string - entry: ResponseCacheEntry | null + entry: IncrementalCacheItem | null expiresAt: number } @@ -45,7 +45,7 @@ export default class ResponseCache implements ResponseCacheBase { this[minimalModeKey] = minimalMode } - public get( + public async get( key: string | null, responseGenerator: ResponseGenerator, context: { @@ -61,7 +61,7 @@ export default class ResponseCache implements ResponseCacheBase { const { incrementalCache, isOnDemandRevalidate = false } = context - return this.batcher.batch( + const response = await this.batcher.batch( { key, isOnDemandRevalidate }, async (cacheKey, resolve) => { // We keep the previous cache entry around to leverage when the @@ -100,19 +100,8 @@ export default class ResponseCache implements ResponseCacheBase { } resolve({ - isStale: cachedResponse.isStale, + ...cachedResponse, revalidate: cachedResponse.curRevalidate, - value: - cachedResponse.value?.kind === 'PAGE' - ? { - kind: 'PAGE', - html: RenderResult.fromStatic(cachedResponse.value.html), - pageData: cachedResponse.value.pageData, - postponed: cachedResponse.value.postponed, - headers: cachedResponse.value.headers, - status: cachedResponse.value.status, - } - : cachedResponse.value, }) resolved = true @@ -123,14 +112,29 @@ export default class ResponseCache implements ResponseCacheBase { } } - const cacheEntry = await responseGenerator(resolved, cachedResponse) - const resolveValue = - cacheEntry === null - ? null - : { - ...cacheEntry, - isMiss: !cachedResponse, - } + const cacheEntry = await responseGenerator( + resolved, + cachedResponse, + true + ) + + // If the cache entry couldn't be generated, we don't want to cache + // the result. + if (!cacheEntry) { + // Unset the previous cache item if it was set. + if (this.minimalMode) this.previousCacheItem = undefined + return null + } + + const resolveValue = await fromResponseCacheEntry({ + ...cacheEntry, + isMiss: !cachedResponse, + }) + if (!resolveValue) { + // Unset the previous cache item if it was set. + if (this.minimalMode) this.previousCacheItem = undefined + return null + } // For on-demand revalidate wait to resolve until cache is set. // Otherwise resolve now. @@ -139,33 +143,18 @@ export default class ResponseCache implements ResponseCacheBase { resolved = true } - if (cacheEntry && typeof cacheEntry.revalidate !== 'undefined') { + if (typeof resolveValue.revalidate !== 'undefined') { if (this.minimalMode) { this.previousCacheItem = { key: cacheKey, - entry: cacheEntry, + entry: resolveValue, expiresAt: Date.now() + 1000, } } else { - await incrementalCache.set( - key, - cacheEntry.value?.kind === 'PAGE' - ? { - kind: 'PAGE', - html: cacheEntry.value.html.toUnchunkedString(), - postponed: cacheEntry.value.postponed, - pageData: cacheEntry.value.pageData, - headers: cacheEntry.value.headers, - status: cacheEntry.value.status, - } - : cacheEntry.value, - { - revalidate: cacheEntry.revalidate, - } - ) + await incrementalCache.set(key, resolveValue.value, { + revalidate: resolveValue.revalidate, + }) } - } else { - this.previousCacheItem = undefined } return resolveValue @@ -193,5 +182,7 @@ export default class ResponseCache implements ResponseCacheBase { } } ) + + return toResponseCacheEntry(response) } } diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 7d76a993f1f97..815371e46d293 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -115,7 +115,8 @@ export type ResponseCacheEntry = { */ export type ResponseGenerator = ( hasResolved: boolean, - previousCacheEntry?: IncrementalCacheItem + previousCacheEntry?: IncrementalCacheItem, + isRevalidating?: boolean ) => Promise export type IncrementalCacheItem = { diff --git a/packages/next/src/server/response-cache/utils.ts b/packages/next/src/server/response-cache/utils.ts new file mode 100644 index 0000000000000..174d6fa619a4a --- /dev/null +++ b/packages/next/src/server/response-cache/utils.ts @@ -0,0 +1,51 @@ +import type { IncrementalCacheItem, ResponseCacheEntry } from './types' + +import RenderResult from '../render-result' + +export async function fromResponseCacheEntry( + cacheEntry: ResponseCacheEntry +): Promise { + return { + ...cacheEntry, + value: + cacheEntry.value?.kind === 'PAGE' + ? { + kind: 'PAGE', + html: await cacheEntry.value.html.toUnchunkedString(true), + postponed: cacheEntry.value.postponed, + pageData: cacheEntry.value.pageData, + headers: cacheEntry.value.headers, + status: cacheEntry.value.status, + } + : cacheEntry.value, + } +} + +export async function toResponseCacheEntry( + response: IncrementalCacheItem +): Promise { + if (!response) return null + + if (response.value?.kind === 'FETCH') { + throw new Error( + 'Invariant: unexpected cachedResponse of kind fetch in response cache' + ) + } + + return { + isMiss: response.isMiss, + isStale: response.isStale, + revalidate: response.revalidate, + value: + response.value?.kind === 'PAGE' + ? { + kind: 'PAGE', + html: RenderResult.fromStatic(response.value.html), + pageData: response.value.pageData, + postponed: response.value.postponed, + headers: response.value.headers, + status: response.value.status, + } + : response.value, + } +} diff --git a/packages/react-dev-overlay/package.json b/packages/react-dev-overlay/package.json index 877233ee81bf8..062f8a83abd7d 100644 --- a/packages/react-dev-overlay/package.json +++ b/packages/react-dev-overlay/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-dev-overlay", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "A development-only overlay for developing React applications.", "repository": { "url": "vercel/next.js", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index 48f16af1b6564..c7cc16b244083 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index e8bc8f20a2bec..1b8f85e8a09f4 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "14.0.3-canary.7", + "version": "14.0.3-canary.8", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -22,7 +22,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "14.0.3-canary.7", + "next": "14.0.3-canary.8", "outdent": "0.8.0", "prettier": "2.5.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac95ed203b47..efb2024ddf6ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -735,7 +735,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.3.3 @@ -800,7 +800,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../next-env '@swc/helpers': specifier: 0.5.2 @@ -924,19 +924,19 @@ importers: specifier: 1.1.0 version: 1.1.0 '@next/polyfill-module': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../next-polyfill-nomodule '@next/react-dev-overlay': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../react-dev-overlay '@next/react-refresh-utils': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../react-refresh-utils '@next/swc': - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1587,7 +1587,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 14.0.3-canary.7 + specifier: 14.0.3-canary.8 version: link:../next outdent: specifier: 0.8.0 diff --git a/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js b/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js new file mode 100644 index 0000000000000..642a064d8fb5b --- /dev/null +++ b/test/e2e/app-dir/navigation/app/search-params/shallow/other/page.js @@ -0,0 +1,5 @@ +import React from 'react' + +export default function Page() { + return

Prefetch target page

+} diff --git a/test/e2e/app-dir/navigation/app/search-params/shallow/page.js b/test/e2e/app-dir/navigation/app/search-params/shallow/page.js new file mode 100644 index 0000000000000..9f012b0ffc2d7 --- /dev/null +++ b/test/e2e/app-dir/navigation/app/search-params/shallow/page.js @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' +import Link from 'next/link' + +export default function Page() { + const setShallowSearchParams = React.useCallback(() => { + // Maintain history state, but set a shallow search param + history.replaceState(history.state, '', '?foo=bar') + }, []) + return ( + <> + + + Then hover me + + + ) +} diff --git a/test/e2e/app-dir/navigation/navigation.test.ts b/test/e2e/app-dir/navigation/navigation.test.ts index 4bf3238bb5308..4a568331ca0f6 100644 --- a/test/e2e/app-dir/navigation/navigation.test.ts +++ b/test/e2e/app-dir/navigation/navigation.test.ts @@ -55,6 +55,17 @@ createNextDescribe( }, 'success') }) + it('should not reset shallow url updates on prefetch', async () => { + const browser = await next.browser('/search-params/shallow') + const button = await browser.elementByCss('button') + await button.click() + expect(await browser.url()).toMatch(/\?foo=bar$/) + const link = await browser.elementByCss('a') + await link.hover() + // Hovering a prefetch link should keep the URL intact + expect(await browser.url()).toMatch(/\?foo=bar$/) + }) + describe('useParams identity between renders', () => { async function runTests(page: string) { const browser = await next.browser(page)