diff --git a/documentation/wiki/ChangeWaves.md b/documentation/wiki/ChangeWaves.md
index 02e5a2f2fee..02a99bf24b4 100644
--- a/documentation/wiki/ChangeWaves.md
+++ b/documentation/wiki/ChangeWaves.md
@@ -29,6 +29,9 @@ Change wave checks around features will be removed in the release that accompani
## Current Rotation of Change Waves
+### 18.10
+- [Restore passes ExcludeRestorePackageImports=true as a global property so NuGet's restore no longer triggers a second evaluation of every project.](https://github.com/dotnet/msbuild/issues/14273)
+
### 18.9
- [GenerateResource: typed ResX data/metadata entries in Mark-of-the-Web files are now treated as untrusted and blocked with MSB3821; unblock the file (or set MSBUILDDISABLEFEATURESFROMVERSION=18.9) to restore prior behavior. ResXFileRef entries are always blocked regardless of this wave.](https://github.com/dotnet/msbuild/pull/14015)
- [TaskHost named-pipe buffers default to 1 MB (was 128 KB), reducing send backpressure for large TaskHostConfiguration packets. Tunable via MSBUILDNODECONNECTIONBUFFERSIZE](https://github.com/dotnet/msbuild/pull/14094)
diff --git a/src/Framework/ChangeWaves.cs b/src/Framework/ChangeWaves.cs
index bd25ffd1b86..808af2d69a6 100644
--- a/src/Framework/ChangeWaves.cs
+++ b/src/Framework/ChangeWaves.cs
@@ -37,7 +37,8 @@ internal static class ChangeWaves
internal static readonly Version Wave18_7 = new Version(18, 7);
internal static readonly Version Wave18_8 = new Version(18, 8);
internal static readonly Version Wave18_9 = new Version(18, 9);
- internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5, Wave18_6, Wave18_7, Wave18_8, Wave18_9];
+ internal static readonly Version Wave18_10 = new Version(18, 10);
+ internal static readonly Version[] AllWaves = [Wave17_10, Wave17_12, Wave17_14, Wave18_3, Wave18_4, Wave18_5, Wave18_6, Wave18_7, Wave18_8, Wave18_9, Wave18_10];
///
/// Special value indicating that all features behind all Change Waves should be enabled.
diff --git a/src/Framework/MSBuildConstants.cs b/src/Framework/MSBuildConstants.cs
index 4e8e8fd29d8..9ab783960d6 100644
--- a/src/Framework/MSBuildConstants.cs
+++ b/src/Framework/MSBuildConstants.cs
@@ -80,6 +80,20 @@ internal static class MSBuildConstants
///
internal const string MSBuildIsRestoring = nameof(MSBuildIsRestoring);
+ ///
+ /// A property set during an implicit restore (/restore) or explicit restore (/t:restore) to instruct projects to exclude imports that are
+ /// provided by NuGet packages. NuGet passes this same global property when it re-invokes the build, so setting it up front ensures the
+ /// initial evaluation is reused rather than being discarded and re-evaluated.
+ ///
+ internal const string ExcludeRestorePackageImports = nameof(ExcludeRestorePackageImports);
+
+ ///
+ /// The value MSBuild assigns to during restore. It must match the literal value NuGet passes
+ /// (lowercase "true" from NuGet.targets) because global property values are compared case-sensitively when matching build configurations
+ /// for evaluation reuse.
+ ///
+ internal const string ExcludeRestorePackageImportsValue = "true";
+
///
/// The most current VSGeneralAssemblyVersion known to this version of MSBuild.
///
diff --git a/src/MSBuild.UnitTests/XMake_Tests.cs b/src/MSBuild.UnitTests/XMake_Tests.cs
index d4bce53e21c..7285bd81853 100644
--- a/src/MSBuild.UnitTests/XMake_Tests.cs
+++ b/src/MSBuild.UnitTests/XMake_Tests.cs
@@ -2615,6 +2615,49 @@ public void RestoreRunsInitialTargets()
logContents.ShouldContain("Restore target ran");
}
+ ///
+ /// Verifies restore sets the ExcludeRestorePackageImports global property so that NuGet's restore reuses the initial
+ /// evaluation instead of forcing a second evaluation of every project.
+ ///
+ [Fact]
+ public void RestoreSetsExcludeRestorePackageImports()
+ {
+ string projectContents = ObjectModelHelpers.CleanupFileContents(
+ @"
+
+
+
+");
+
+ string logContents = ExecuteMSBuildExeExpectSuccess(projectContents, arguments: "/t:restore");
+
+ // The value must be the literal lowercase "true" to exactly match the global property NuGet's restore passes
+ // (NuGet.targets sets ExcludeRestorePackageImports=true), otherwise evaluation reuse would not occur.
+ logContents.ShouldContain($"ExcludeRestorePackageImports=[{MSBuildConstants.ExcludeRestorePackageImportsValue}]");
+ }
+
+ ///
+ /// Verifies restore does not set the ExcludeRestorePackageImports global property when change wave 18.10 is disabled,
+ /// preserving the prior behavior for repositories that opt out.
+ ///
+ [Fact]
+ public void RestoreDoesNotSetExcludeRestorePackageImportsWhenWaveDisabled()
+ {
+ string projectContents = ObjectModelHelpers.CleanupFileContents(
+ @"
+
+
+
+");
+
+ Dictionary envVars = new() { { "MSBUILDDISABLEFEATURESFROMVERSION", ChangeWaves.Wave18_10.ToString() } };
+
+ string logContents = ExecuteMSBuildExeExpectSuccess(projectContents, envsToCreate: envVars, arguments: "/t:restore");
+
+ // When the wave is disabled, the property must not be injected.
+ logContents.ShouldContain("ExcludeRestorePackageImports=[]");
+ }
+
///
/// We check if there is only one target name specified and this logic caused a regression: https://github.com/dotnet/msbuild/issues/3317
///
diff --git a/src/MSBuild/XMake.cs b/src/MSBuild/XMake.cs
index 6f29cbac80b..6111509324c 100644
--- a/src/MSBuild/XMake.cs
+++ b/src/MSBuild/XMake.cs
@@ -2064,6 +2064,19 @@ private static BuildResult ExecuteRestore(string projectFile, string toolsVersio
// Add a property to indicate that a Restore is executing
restoreGlobalProperties[MSBuildConstants.MSBuildIsRestoring] = bool.TrueString;
+ // Add the property that NuGet passes when it re-invokes the build during restore. NuGet's restore targets pass
+ // ExcludeRestorePackageImports=true as a global property, which normally forces a second evaluation of every project.
+ // Setting it up front here means the initial evaluation already matches, so it is reused instead of being discarded.
+ // The value must match NuGet's exactly (the literal lowercase "true" from NuGet.targets) because global property values
+ // are compared case-sensitively when matching build configurations for evaluation reuse.
+ // Only set it when the user has not explicitly supplied a value so that an explicit opt-out (e.g.
+ // /p:ExcludeRestorePackageImports=false) is respected instead of being silently overwritten.
+ if (ChangeWaves.AreFeaturesEnabled(ChangeWaves.Wave18_10)
+ && !globalProperties.ContainsKey(MSBuildConstants.ExcludeRestorePackageImports))
+ {
+ restoreGlobalProperties[MSBuildConstants.ExcludeRestorePackageImports] = MSBuildConstants.ExcludeRestorePackageImportsValue;
+ }
+
// Create a new request with a Restore target only and specify:
// - BuildRequestDataFlags.ClearCachesAfterBuild to ensure the projects will be reloaded from disk for subsequent builds
// - BuildRequestDataFlags.SkipNonexistentTargets to ignore missing targets since Restore does not require that all targets exist