Compare commits

...

3 Commits

Author SHA1 Message Date
Philip Fulcher 18dda8527f docs(nx-dev): remove December 2025 webinar notifier (#33911) 2025-12-17 23:04:09 -05:00
Craigory Coppola 16ba3ad931 fix(core): ensure no tui on single tasks (#33910)
## Current Behavior
The tui shouldn't show for single tasks

## Expected Behavior
The tui isn't shown for single tasks

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
2025-12-17 23:04:08 -05:00
Craigory Coppola 54ce2863d0 fix(dotnet): fix dependency graph for multi-targeting and transitive deps (#33908)
## Current Behavior

  Two issues with the .NET dependency graph:

1. Issue #33653: When a .NET project uses multi-targeting
(<TargetFrameworks> plural), its project reference dependencies
disappear from the Nx graph.
2. Issue #33397: Transitive dependencies are incorrectly shown as direct
dependencies. For example, if A → B → C, the graph shows A depending on
both B and C, when it should only show A → B.

##  Expected Behavior

1. Multi-targeting projects should correctly show their dependencies in
the graph.
2. Only direct dependencies should be shown, not transitive ones. Nx
handles transitive dependencies through the dependency chain.

 ### Solution

  Multi-targeting fix (#33653)

  MSBuild creates multiple nodes for multi-targeting projects:
  - An "outer build" with TargetFrameworks set but TargetFramework empty
  - "Inner builds" for each target framework with TargetFramework set

The fix groups nodes by project file path and prefers inner builds
(which have properly resolved references) over outer builds.

 ### Transitive dependency fix (#33397)

Changed from using ProjectGraphNode.ProjectReferences (which includes
transitive dependencies in multi-targeting scenarios) to
ProjectInstance.GetItems("ProjectReference") which returns only direct
references defined in the project file.

 ## Related Issue(s)

  Fixes #33653
  Fixes #33397

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 23:04:07 -05:00
7 changed files with 335 additions and 128 deletions
@@ -7,7 +7,7 @@ authors: ['Nicole Oliver', 'Miroslav Jonas', 'Victor Savkin']
tags: [webinar]
cover_image: /blog/images/2025-12-17/2025-December-webinar-card.avif
time: 2pm ET/7pm UTC
status: Upcoming
status: Past - Gated
registrationUrl: https://go.nx.dev/dec2025-webinar
---
@@ -21,4 +21,4 @@ Whether youre a longtime Nx user or considering it for your team, this sessio
Bah humbug to slow builds and CI bottlenecks—lets build something better together.
{% call-to-action title="Register today!" url="https://go.nx.dev/dec2025-webinar" description="Save your spot" /%}
{% call-to-action title="Download the recording!" url="https://go.nx.dev/dec2025-webinar" description="Sign up to gain access" /%}
@@ -0,0 +1,163 @@
import {
checkFilesExist,
cleanupProject,
newProject,
readJson,
runCLI,
runCommand,
tmpProjPath,
} from '@nx/e2e-utils';
import {
addProjectReference,
createDotNetProject,
enableMultiTargeting,
} from './utils/create-dotnet-project';
interface GraphDependency {
source: string;
target: string;
type: string;
}
describe('.NET Plugin - Dependency Graph', () => {
beforeAll(() => {
console.log('Creating new Nx workspace');
newProject({ packages: [] });
runCLI(`add @nx/dotnet`, { verbose: true });
console.log('Nx workspace created');
});
afterAll(() => {
cleanupProject();
console.log('Nx workspace cleaned up');
});
describe('Multi-targeting projects', () => {
beforeAll(() => {
// Create a chain: MultiTargetLib -> SingleTargetLib -> BaseLib
createDotNetProject({ name: 'BaseLib', type: 'classlib' });
createDotNetProject({ name: 'SingleTargetLib', type: 'classlib' });
createDotNetProject({ name: 'MultiTargetLib', type: 'classlib' });
createDotNetProject({ name: 'ConsumerApp', type: 'console' });
// Set up the dependency chain
addProjectReference('SingleTargetLib', 'BaseLib');
addProjectReference('MultiTargetLib', 'SingleTargetLib');
addProjectReference('ConsumerApp', 'MultiTargetLib');
// Enable multi-targeting on all libs in the chain and restore each to update assets
enableMultiTargeting('BaseLib', ['net8.0', 'net9.0']);
runCommand('dotnet restore BaseLib', { cwd: tmpProjPath() });
enableMultiTargeting('SingleTargetLib', ['net8.0', 'net9.0']);
runCommand('dotnet restore SingleTargetLib', { cwd: tmpProjPath() });
enableMultiTargeting('MultiTargetLib', ['net8.0', 'net9.0']);
runCommand('dotnet restore MultiTargetLib', { cwd: tmpProjPath() });
});
it('should detect dependencies for multi-targeting projects', () => {
runCLI('graph --file=multi-target-graph.json');
checkFilesExist('multi-target-graph.json');
const { graph } = readJson('multi-target-graph.json');
// MultiTargetLib should have SingleTargetLib as a dependency
const multiTargetDeps: GraphDependency[] =
graph.dependencies['MultiTargetLib'] || [];
expect(
multiTargetDeps.some((dep) => dep.target === 'SingleTargetLib')
).toBe(true);
// SingleTargetLib should have BaseLib as a dependency
const singleTargetDeps: GraphDependency[] =
graph.dependencies['SingleTargetLib'] || [];
expect(singleTargetDeps.some((dep) => dep.target === 'BaseLib')).toBe(
true
);
// ConsumerApp should have MultiTargetLib as a dependency
const consumerDeps: GraphDependency[] =
graph.dependencies['ConsumerApp'] || [];
expect(consumerDeps.some((dep) => dep.target === 'MultiTargetLib')).toBe(
true
);
});
it('should build multi-targeting projects with dependencies', () => {
const output = runCLI('build ConsumerApp --verbose', { verbose: true });
expect(output).toContain('Build succeeded');
});
});
describe('Transitive dependencies', () => {
beforeAll(() => {
// Create a chain: AppProject -> MiddleLib -> LeafLib
// We want to verify AppProject only shows MiddleLib as direct dep,
// not LeafLib (which is a transitive dependency)
createDotNetProject({ name: 'LeafLib', type: 'classlib' });
createDotNetProject({ name: 'MiddleLib', type: 'classlib' });
createDotNetProject({ name: 'AppProject', type: 'console' });
// Set up the dependency chain
addProjectReference('MiddleLib', 'LeafLib');
addProjectReference('AppProject', 'MiddleLib');
});
it('should only show direct dependencies, not transitive ones', () => {
runCLI('graph --file=transitive-graph.json');
checkFilesExist('transitive-graph.json');
const { graph } = readJson('transitive-graph.json');
// AppProject should ONLY have MiddleLib as dependency
const appDeps: GraphDependency[] = graph.dependencies['AppProject'] || [];
const appProjectRefs = appDeps.filter(
(dep) => dep.type === 'static' || dep.type === 'implicit'
);
// Should have MiddleLib
expect(appProjectRefs.some((dep) => dep.target === 'MiddleLib')).toBe(
true
);
// Should NOT have LeafLib (that's a transitive dependency)
expect(appProjectRefs.some((dep) => dep.target === 'LeafLib')).toBe(
false
);
// MiddleLib should have LeafLib as dependency
const middleDeps: GraphDependency[] =
graph.dependencies['MiddleLib'] || [];
expect(middleDeps.some((dep) => dep.target === 'LeafLib')).toBe(true);
});
it('should correctly show transitive deps for multi-targeting projects', () => {
// Enable multi-targeting to test the combined scenario
enableMultiTargeting('LeafLib', ['net8.0', 'net9.0']);
runCommand('dotnet restore LeafLib', { cwd: tmpProjPath() });
enableMultiTargeting('MiddleLib', ['net8.0', 'net9.0']);
runCommand('dotnet restore MiddleLib', { cwd: tmpProjPath() });
runCLI('graph --file=transitive-multi-graph.json');
checkFilesExist('transitive-multi-graph.json');
const { graph } = readJson('transitive-multi-graph.json');
// AppProject should still ONLY have MiddleLib as direct dependency
const appDeps: GraphDependency[] = graph.dependencies['AppProject'] || [];
const appProjectRefs = appDeps.filter(
(dep) => dep.type === 'static' || dep.type === 'implicit'
);
expect(appProjectRefs.some((dep) => dep.target === 'MiddleLib')).toBe(
true
);
expect(appProjectRefs.some((dep) => dep.target === 'LeafLib')).toBe(
false
);
});
});
});
+84 -80
View File
@@ -1,89 +1,93 @@
'use client';
import { MouseEvent, ReactElement, useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import {
MegaphoneIcon,
VideoCameraIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { ReactElement } from 'react';
// import { MouseEvent, ReactElement, useEffect, useState } from 'react';
// import { motion } from 'framer-motion';
// import {
// MegaphoneIcon,
// VideoCameraIcon,
// XMarkIcon,
// } from '@heroicons/react/24/outline';
export function WebinarNotifier(): ReactElement | null {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState<boolean>(true);
const localStorageKey = 'webinar-december-2025--notifier-closed';
return null;
useEffect(() => {
setIsMounted(true);
const isClosedSession = localStorage.getItem(localStorageKey);
if (isClosedSession === 'true') {
setIsVisible(false);
}
}, []);
// const [isMounted, setIsMounted] = useState(false);
// const [isVisible, setIsVisible] = useState<boolean>(true);
// const localStorageKey = 'webinar-december-2025--notifier-closed';
const closeNotifier = (e: MouseEvent) => {
e.stopPropagation();
setIsVisible(false);
localStorage.setItem(localStorageKey, 'true');
};
// useEffect(() => {
// setIsMounted(true);
// const isClosedSession = localStorage.getItem(localStorageKey);
// if (isClosedSession === 'true') {
// setIsVisible(false);
// }
// }, []);
if (!isMounted || !isVisible) return null;
// const closeNotifier = (e: MouseEvent) => {
// e.stopPropagation();
// setIsVisible(false);
// localStorage.setItem(localStorageKey, 'true');
// };
return (
<motion.div
layout
initial={{ y: '120%' }}
animate={{ y: 0 }}
exit={{ y: '120%' }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
mass: 1,
}}
className="fixed bottom-0 left-0 right-0 z-30 w-full overflow-hidden bg-slate-950 text-white shadow-lg md:bottom-4 md:left-auto md:right-4 md:w-[512px] md:rounded-lg"
style={{ originY: 1 }}
>
<div className="relative p-4">
<button
onClick={closeNotifier}
className="absolute right-2 top-2 flex h-9 w-9 cursor-pointer items-center justify-center !rounded-full bg-transparent p-1 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-white"
>
<XMarkIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Close</span>
</button>
<div>
<motion.h3
layout="position"
className="flex items-center gap-2 pr-8 text-lg font-semibold"
>
<MegaphoneIcon
aria-hidden="true"
className="size-8 flex-shrink-0"
/>
<span>Bah humbug to slow builds and CI bottlenecks!</span>
</motion.h3>
<motion.div key="live-event" className="mt-4 space-y-4">
<p className="mb-2 text-sm">
Join us for a special year-end webinar on Dec. 17th. Well take
you on a journey through Nxs past, present, and future to explore
the evolution of Nx and whats coming next.
</p>
<div className="flex flex-wrap items-center justify-end gap-1 sm:gap-4">
<a
title="Signup"
href="https://bit.ly/4q9sgzV"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-pink-600 px-2 py-2 text-sm font-semibold text-white no-underline transition hover:bg-pink-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:text-black/70 md:px-4"
>
<VideoCameraIcon aria-hidden="true" className="size-4" />
<span>Sign Up Now</span>
</a>
</div>
</motion.div>
</div>
</div>
</motion.div>
);
// if (!isMounted || !isVisible) return null;
// return (
// <motion.div
// layout
// initial={{ y: '120%' }}
// animate={{ y: 0 }}
// exit={{ y: '120%' }}
// transition={{
// type: 'spring',
// stiffness: 300,
// damping: 30,
// mass: 1,
// }}
// className="fixed bottom-0 left-0 right-0 z-30 w-full overflow-hidden bg-slate-950 text-white shadow-lg md:bottom-4 md:left-auto md:right-4 md:w-[512px] md:rounded-lg"
// style={{ originY: 1 }}
// >
// <div className="relative p-4">
// <button
// onClick={closeNotifier}
// className="absolute right-2 top-2 flex h-9 w-9 cursor-pointer items-center justify-center !rounded-full bg-transparent p-1 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-white"
// >
// <XMarkIcon className="size-5" aria-hidden="true" />
// <span className="sr-only">Close</span>
// </button>
// <div>
// <motion.h3
// layout="position"
// className="flex items-center gap-2 pr-8 text-lg font-semibold"
// >
// <MegaphoneIcon
// aria-hidden="true"
// className="size-8 flex-shrink-0"
// />
// <span>Bah humbug to slow builds and CI bottlenecks!</span>
// </motion.h3>
// <motion.div key="live-event" className="mt-4 space-y-4">
// <p className="mb-2 text-sm">
// Join us for a special year-end webinar on Dec. 17th. Well take
// you on a journey through Nxs past, present, and future to explore
// the evolution of Nx and whats coming next.
// </p>
// <div className="flex flex-wrap items-center justify-end gap-1 sm:gap-4">
// <a
// title="Signup"
// href="https://bit.ly/4q9sgzV"
// target="_blank"
// rel="noopener noreferrer"
// className="inline-flex items-center justify-center gap-2 rounded-lg bg-pink-600 px-2 py-2 text-sm font-semibold text-white no-underline transition hover:bg-pink-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:text-black/70 md:px-4"
// >
// <VideoCameraIcon aria-hidden="true" className="size-4" />
// <span>Sign Up Now</span>
// </a>
// </div>
// </motion.div>
// </div>
// </div>
// </motion.div>
// );
}
@@ -1,25 +1,27 @@
import React from 'react';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
// import { ChevronRightIcon } from '@heroicons/react/24/outline';
export const WebinarSection: React.FC = () => {
return (
<p>
<a
href="https://bit.ly/4q9sgzV"
title="See live event in details"
className="group/event-link inline-flex space-x-6"
>
<span className="rounded-full bg-blue-600/10 px-3 py-1 text-sm/6 font-semibold text-blue-600 ring-1 ring-inset ring-blue-600/10 dark:bg-cyan-600/10 dark:text-cyan-600 dark:ring-cyan-600/10">
Live event
</span>
<span className="inline-flex items-center space-x-2 text-sm/6 font-medium">
<span>Webinar on November 19th</span>
<ChevronRightIcon
aria-hidden="true"
className="size-5 transform transition-all group-hover/event-link:translate-x-1"
/>
</span>
</a>
</p>
);
return null;
// return (
// <p>
// <a
// href="https://bit.ly/4q9sgzV"
// title="See live event in details"
// className="group/event-link inline-flex space-x-6"
// >
// <span className="rounded-full bg-blue-600/10 px-3 py-1 text-sm/6 font-semibold text-blue-600 ring-1 ring-inset ring-blue-600/10 dark:bg-cyan-600/10 dark:text-cyan-600 dark:ring-cyan-600/10">
// Live event
// </span>
// <span className="inline-flex items-center space-x-2 text-sm/6 font-medium">
// <span>Webinar on November 19th</span>
// <ChevronRightIcon
// aria-hidden="true"
// className="size-5 transform transition-all group-hover/event-link:translate-x-1"
// />
// </span>
// </a>
// </p>
// );
};
+58 -22
View File
@@ -59,40 +59,68 @@ public static class Analyzer
var nodesByFile = new Dictionary<string, NxProjectGraphNode>();
var referencesByRoot = new Dictionary<string, ReferencesInfo>();
using (var analyzeProjectsPerf = PerfLogger.Start($"analyze workspace > transform {projectGraph.ProjectNodes.Count} projects"))
// Group nodes by project file path to handle multi-targeting projects.
// Multi-targeting projects (using TargetFrameworks plural) create multiple nodes:
// - An "outer build" with TargetFrameworks set but TargetFramework empty
// - "Inner builds" for each target framework with TargetFramework set
// We need to aggregate references from all builds and use an inner build for config.
var nodesByPath = new Dictionary<string, List<ProjectGraphNode>>();
foreach (var node in projectGraph.ProjectNodes)
{
foreach (var node in projectGraph.ProjectNodes)
if (node.ProjectInstance?.FullPath is null)
{
continue;
}
var path = node.ProjectInstance.FullPath;
if (!nodesByPath.TryGetValue(path, out var nodes))
{
nodes = new List<ProjectGraphNode>();
nodesByPath[path] = nodes;
}
nodes.Add(node);
}
using (var analyzeProjectsPerf = PerfLogger.Start($"analyze workspace > transform {nodesByPath.Count} projects"))
{
foreach (var kvp in nodesByPath)
{
var projectPath = kvp.Key;
var nodes = kvp.Value;
try
{
if (node.ProjectInstance is null)
// For multi-targeting projects, prefer an inner build (has TargetFramework set)
// over the outer build (has TargetFrameworks but no TargetFramework).
// Inner builds have the actual project references.
var primaryNode = nodes.FirstOrDefault(n =>
!string.IsNullOrEmpty(n.ProjectInstance?.GetPropertyValue("TargetFramework")))
?? nodes.First();
if (primaryNode.ProjectInstance is null)
{
throw new InvalidOperationException("ProjectInstance is null.");
}
var projectPath = node.ProjectInstance.FullPath;
if (string.IsNullOrEmpty(projectPath))
{
continue;
}
var projectRoot = ProjectUtilities.GetRelativeProjectRoot(projectPath, workspaceRoot);
var relativeProjectFile = ProjectUtilities.GetRelativeProjectFile(projectPath, workspaceRoot);
// Collect package references
var packageRefs = CollectPackageReferences(node.ProjectInstance!);
// Collect package references from primary node
var packageRefs = CollectPackageReferences(primaryNode.ProjectInstance!);
// Collect project references
var projectRefs = CollectProjectReferences(node, projectPath, workspaceRoot);
// Collect direct project references from the primary node's ProjectInstance.
// Uses GetItems("ProjectReference") which returns only DIRECT references,
// with glob patterns already evaluated by MSBuild during project loading.
var projectRefs = CollectProjectReferences(primaryNode.ProjectInstance!, projectPath, workspaceRoot);
// Collect MSBuild properties
var properties = CollectProperties(node.ProjectInstance!);
// Collect MSBuild properties from primary node
var properties = CollectProperties(primaryNode.ProjectInstance!);
// Determine project type
var isTest = IsTestProject(properties, packageRefs);
var isExe = IsExecutableProject(properties);
// Build targets
var projectName = ProjectUtilities.GetProjectName(node.ProjectInstance);
var projectName = ProjectUtilities.GetProjectName(primaryNode.ProjectInstance);
var targets = TargetBuilder.BuildTargets(
projectName,
Path.GetFileName(projectPath),
@@ -127,7 +155,7 @@ public static class Analyzer
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error analyzing {node.ProjectInstance?.FullPath}: {ex.Message}");
Console.Error.WriteLine($"Error analyzing {projectPath}: {ex.Message}");
}
}
}
@@ -155,24 +183,32 @@ public static class Analyzer
return packageRefs;
}
/// <summary>
/// Collects direct project references from the ProjectInstance.
/// Uses GetItems("ProjectReference") to get only direct references defined in the project file.
/// MSBuild evaluates glob patterns during project loading, so EvaluatedInclude contains
/// resolved paths even when the original ProjectReference used globs.
/// </summary>
private static List<string> CollectProjectReferences(
ProjectGraphNode project,
ProjectInstance project,
string projectPath,
string workspaceRoot)
{
var projectRefs = new List<string>();
var projectDir = Path.GetDirectoryName(projectPath)!;
foreach (var referencedProject in project.ProjectReferences)
foreach (var item in project.GetItems("ProjectReference"))
{
var refPath = referencedProject.ProjectInstance?.FullPath;
var refPath = item.EvaluatedInclude;
if (string.IsNullOrEmpty(refPath))
{
throw new InvalidOperationException(
$"Project reference in {projectPath} is missing a valid path.");
continue;
}
// Resolve the path relative to the project directory
var absoluteRefPath = Path.IsPathRooted(refPath)
? refPath
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(projectPath)!, refPath));
: Path.GetFullPath(Path.Combine(projectDir, refPath));
var relativeRefRoot = ProjectUtilities.GetRelativeProjectRoot(absoluteRefPath, workspaceRoot);
projectRefs.Add(relativeRefRoot);
@@ -4,6 +4,8 @@ import { NxArgs } from '../utils/command-line-utils';
import { isCI } from '../utils/is-ci';
import { logger } from '../utils/logger';
export const ORIGINAL_TUI_ENV_VALUE = process.env.NX_TUI;
/**
* @returns If tui is enabled
*/
+3 -3
View File
@@ -40,7 +40,7 @@ import {
} from '../utils/sync-generators';
import { workspaceRoot } from '../utils/workspace-root';
import { createTaskGraph } from './create-task-graph';
import { isTuiEnabled } from './is-tui-enabled';
import { isTuiEnabled, ORIGINAL_TUI_ENV_VALUE } from './is-tui-enabled';
import {
CompositeLifeCycle,
LifeCycle,
@@ -94,8 +94,8 @@ async function getTerminalOutputLifeCycle(
const isRunOne = initiatingProject != null;
if (tasks.length === 1) {
process.env.NX_TUI ??= 'false';
if (tasks.length === 1 && !ORIGINAL_TUI_ENV_VALUE) {
process.env.NX_TUI = 'false';
}
if (isTuiEnabled()) {