diff --git a/graph/client/src/app/routes.tsx b/graph/client/src/app/routes.tsx index 2fc24977988ac4..9b247bba98d915 100644 --- a/graph/client/src/app/routes.tsx +++ b/graph/client/src/app/routes.tsx @@ -1,12 +1,16 @@ -import { redirect, RouteObject } from 'react-router-dom'; +import { redirect, RouteObject, json } from 'react-router-dom'; import { ProjectsSidebar } from './feature-projects/projects-sidebar'; import { TasksSidebar } from './feature-tasks/tasks-sidebar'; import { Shell } from './shell'; /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line -import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; +import type { + GraphError, + ProjectGraphClientResponse, +} from 'nx/src/command-line/graph/graph'; // nx-ignore-next-line import type { ProjectGraphProjectNode } from 'nx/src/config/project-graph'; +/* eslint-enable @nx/enforce-module-boundaries */ import { getEnvironmentConfig, getProjectGraphDataService, @@ -78,6 +82,7 @@ const projectDetailsLoader = async ( hash: string; project: ProjectGraphProjectNode; sourceMap: Record; + errors?: GraphError[]; }> => { const workspaceData = await workspaceDataLoader(selectedWorkspaceId); const sourceMaps = await sourceMapsLoader(selectedWorkspaceId); @@ -85,10 +90,18 @@ const projectDetailsLoader = async ( const project = workspaceData.projects.find( (project) => project.name === projectName ); + if (!project) { + throw json({ + id: 'project-not-found', + projectName, + errors: workspaceData.errors, + }); + } return { hash: workspaceData.hash, project, sourceMap: sourceMaps[project.data.root], + errors: workspaceData.errors, }; }; diff --git a/graph/client/src/app/shell.tsx b/graph/client/src/app/shell.tsx index a670617bf57a83..d8fed74e304778 100644 --- a/graph/client/src/app/shell.tsx +++ b/graph/client/src/app/shell.tsx @@ -1,30 +1,48 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { + GraphError, + ProjectGraphClientResponse, +} from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ + import { ArrowDownTrayIcon, ArrowLeftCircleIcon, InformationCircleIcon, } from '@heroicons/react/24/outline'; +import { + ErrorToast, + fetchProjectGraph, + getProjectGraphDataService, + useEnvironmentConfig, + useIntervalWhen, +} from '@nx/graph/shared'; +import { Dropdown, Spinner } from '@nx/graph/ui-components'; +import { getSystemTheme, Theme, ThemePanel } from '@nx/graph/ui-theme'; +import { Tooltip } from '@nx/graph/ui-tooltips'; import classNames from 'classnames'; -import { DebuggerPanel } from './ui-components/debugger-panel'; -import { getGraphService } from './machines/graph.service'; +import { useLayoutEffect, useState } from 'react'; import { Outlet, useNavigate, useNavigation, useParams, + useRouteLoaderData, } from 'react-router-dom'; -import { getSystemTheme, Theme, ThemePanel } from '@nx/graph/ui-theme'; -import { Dropdown, Spinner } from '@nx/graph/ui-components'; -import { useCurrentPath } from './hooks/use-current-path'; -import { ExperimentalFeature } from './ui-components/experimental-feature'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RankdirPanel } from './feature-projects/panels/rankdir-panel'; +import { useCurrentPath } from './hooks/use-current-path'; import { getProjectGraphService } from './machines/get-services'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { Tooltip } from '@nx/graph/ui-tooltips'; +import { getGraphService } from './machines/graph.service'; +import { DebuggerPanel } from './ui-components/debugger-panel'; +import { ExperimentalFeature } from './ui-components/experimental-feature'; import { TooltipDisplay } from './ui-tooltips/graph-tooltip-display'; -import { useEnvironmentConfig } from '@nx/graph/shared'; export function Shell(): JSX.Element { const projectGraphService = getProjectGraphService(); + const projectGraphDataService = getProjectGraphDataService(); + const graphService = getGraphService(); const lastPerfReport = useSyncExternalStore( @@ -43,9 +61,30 @@ export function Shell(): JSX.Element { const navigate = useNavigate(); const { state: navigationState } = useNavigation(); const currentPath = useCurrentPath(); - const { selectedWorkspaceId } = useParams(); + const params = useParams(); const currentRoute = currentPath.currentPath; + const [errors, setErrors] = useState(undefined); + const { errors: routerErrors } = useRouteLoaderData('selectedWorkspace') as { + errors: GraphError[]; + }; + useLayoutEffect(() => { + setErrors(routerErrors); + }, [routerErrors]); + useIntervalWhen( + () => { + fetchProjectGraph( + projectGraphDataService, + params, + environmentConfig.appConfig + ).then((response: ProjectGraphClientResponse) => { + setErrors(response.errors); + }); + }, + 1000, + environmentConfig.watch + ); + const topLevelRoute = currentRoute.startsWith('/tasks') ? '/tasks' : '/projects'; @@ -165,7 +204,7 @@ export function Shell(): JSX.Element { {environment.appConfig.showDebugger ? ( @@ -217,6 +256,7 @@ export function Shell(): JSX.Element { + ); } diff --git a/graph/client/src/app/ui-components/error-boundary.tsx b/graph/client/src/app/ui-components/error-boundary.tsx index 561a95a691be5b..33106aa6b249e5 100644 --- a/graph/client/src/app/ui-components/error-boundary.tsx +++ b/graph/client/src/app/ui-components/error-boundary.tsx @@ -1,27 +1,100 @@ -import { useEnvironmentConfig } from '@nx/graph/shared'; +import { + fetchProjectGraph, + getProjectGraphDataService, + useEnvironmentConfig, + useIntervalWhen, +} from '@nx/graph/shared'; +import { ErrorRenderer } from '@nx/graph/ui-components'; import { ProjectDetailsHeader } from 'graph/project-details/src/lib/project-details-header'; -import { useRouteError } from 'react-router-dom'; +import { + isRouteErrorResponse, + useParams, + useRouteError, +} from 'react-router-dom'; export function ErrorBoundary() { - let error = useRouteError()?.toString(); + let error = useRouteError(); console.error(error); - const environment = useEnvironmentConfig()?.environment; - let message = 'Disconnected from graph server. '; - if (environment === 'nx-console') { - message += 'Please refresh the page.'; + const { environment, appConfig, watch } = useEnvironmentConfig(); + const projectGraphDataService = getProjectGraphDataService(); + const params = useParams(); + + const hasErrorData = + isRouteErrorResponse(error) && error.data.errors?.length > 0; + + useIntervalWhen( + async () => { + fetchProjectGraph(projectGraphDataService, params, appConfig).then( + (data) => { + if ( + isRouteErrorResponse(error) && + error.data.id === 'project-not-found' && + data.projects.find((p) => p.name === error.data.projectName) + ) { + window.location.reload(); + } + return; + } + ); + }, + 1000, + watch + ); + + let message: string | JSX.Element; + let stack: string; + if (isRouteErrorResponse(error) && error.data.id === 'project-not-found') { + message = ( +

+ Project {error.data.projectName} not found. +

+ ); } else { - message += 'Please rerun your command and refresh the page.'; + message = 'Disconnected from graph server. '; + if (environment === 'nx-console') { + message += 'Please refresh the page.'; + } else { + message += 'Please rerun your command and refresh the page.'; + } + stack = error.toString(); } return (
-

Error

-
-

{message}

-

Error message: {error}

+
+

Error

+
+ +
+ {hasErrorData && ( +
+

+ Nx encountered the following issues while processing the project + graph:{' '} +

+
+ +
+
+ )}
); } + +function ErrorWithStack({ + message, + stack, +}: { + message: string | JSX.Element; + stack?: string; +}) { + return ( +
+

{message}

+ {stack &&

Error message: {stack}

} +
+ ); +} diff --git a/graph/client/src/app/ui-components/project-details-modal.tsx b/graph/client/src/app/ui-components/project-details-modal.tsx index 7ee2a4ca8a9bb7..d693c42e5392a1 100644 --- a/graph/client/src/app/ui-components/project-details-modal.tsx +++ b/graph/client/src/app/ui-components/project-details-modal.tsx @@ -1,11 +1,11 @@ /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line +import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; +// nx-ignore-next-line +import { ProjectDetailsWrapper } from '@nx/graph/project-details'; +/* eslint-enable @nx/enforce-module-boundaries */ import { useFloating } from '@floating-ui/react'; import { XMarkIcon } from '@heroicons/react/24/outline'; -import { ProjectDetailsWrapper } from '@nx/graph/project-details'; -/* eslint-disable @nx/enforce-module-boundaries */ -// nx-ignore-next-line -import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; import { useEffect, useState } from 'react'; import { useRouteLoaderData, useSearchParams } from 'react-router-dom'; diff --git a/graph/project-details/src/lib/project-details-page.tsx b/graph/project-details/src/lib/project-details-page.tsx index c6e85a8992d86d..16a3267afde211 100644 --- a/graph/project-details/src/lib/project-details-page.tsx +++ b/graph/project-details/src/lib/project-details-page.tsx @@ -1,6 +1,10 @@ /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line import { ProjectGraphProjectNode } from '@nx/devkit'; +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ + import { ScrollRestoration, useParams, @@ -16,12 +20,13 @@ import { import { ProjectDetailsHeader } from './project-details-header'; export function ProjectDetailsPage() { - const { project, sourceMap, hash } = useRouteLoaderData( + const { project, sourceMap, hash, errors } = useRouteLoaderData( 'selectedProjectDetails' ) as { hash: string; project: ProjectGraphProjectNode; sourceMap: Record; + errors?: GraphError[]; }; const { environment, watch, appConfig } = useEnvironmentConfig(); @@ -56,6 +61,7 @@ export function ProjectDetailsPage() {
diff --git a/graph/project-details/src/lib/project-details-wrapper.tsx b/graph/project-details/src/lib/project-details-wrapper.tsx index e9c56339741b8f..cb537984411bc1 100644 --- a/graph/project-details/src/lib/project-details-wrapper.tsx +++ b/graph/project-details/src/lib/project-details-wrapper.tsx @@ -4,8 +4,12 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line -import { ProjectGraphProjectNode } from '@nx/devkit'; +import type { ProjectGraphProjectNode } from '@nx/devkit'; +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ import { + ErrorToast, getExternalApiService, useEnvironmentConfig, useRouteConstructor, @@ -19,6 +23,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react'; export interface ProjectDetailsProps { project: ProjectGraphProjectNode; sourceMap: Record; + errors?: GraphError[]; } export function ProjectDetailsWrapper(props: ProjectDetailsProps) { @@ -144,15 +149,18 @@ export function ProjectDetailsWrapper(props: ProjectDetailsProps) { }, [searchParams, props.project.data.targets, projectDetailsRef]); return ( - + <> + + + ); } diff --git a/graph/shared/src/index.ts b/graph/shared/src/index.ts index dd2268fea7ad8c..7418b557e2742d 100644 --- a/graph/shared/src/index.ts +++ b/graph/shared/src/index.ts @@ -6,3 +6,4 @@ export * from './lib/use-route-constructor'; export * from './lib/use-interval-when'; export * from './lib/project-graph-data-service/get-project-graph-data-service'; export * from './lib/fetch-project-graph'; +export * from './lib/error-toast'; diff --git a/graph/shared/src/lib/error-toast.tsx b/graph/shared/src/lib/error-toast.tsx new file mode 100644 index 00000000000000..4d3e6b05279340 --- /dev/null +++ b/graph/shared/src/lib/error-toast.tsx @@ -0,0 +1,120 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ + +import { + createRef, + ForwardedRef, + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, +} from 'react'; + +import { Transition } from '@headlessui/react'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { SetURLSearchParams, useSearchParams } from 'react-router-dom'; +import { ErrorRenderer, Modal, ModalHandle } from '@nx/graph/ui-components'; + +export interface ErrorToastImperativeHandle { + closeModal: () => void; + openModal: () => void; +} + +interface ErrorToastProps { + errors: GraphError[] | undefined; +} + +export const ErrorToast = forwardRef( + ( + { errors }: ErrorToastProps, + ref: ForwardedRef + ) => { + const inputsModalRef = createRef(); + + const [searchParams, setSearchParams] = useSearchParams(); + + useImperativeHandle(ref, () => ({ + openModal: () => { + inputsModalRef?.current?.openModal(); + }, + closeModal: () => { + inputsModalRef?.current?.closeModal(); + }, + })); + + const handleModalOpen = useCallback(() => { + if (searchParams.get('show-error') === 'true') return; + setSearchParams( + (currentSearchParams) => { + currentSearchParams.set('show-error', 'true'); + return currentSearchParams; + }, + { replace: true, preventScrollReset: true } + ); + }, [setSearchParams, searchParams]); + + const handleModalClose = useCallback(() => { + if (!searchParams.get('show-error')) return; + setSearchParams( + (currentSearchParams) => { + currentSearchParams.delete('show-error'); + return currentSearchParams; + }, + { replace: true, preventScrollReset: true } + ); + }, [setSearchParams, searchParams]); + + useLayoutEffect(() => { + if (searchParams.get('show-error') === 'true') { + inputsModalRef.current?.openModal(); + } + }, [searchParams, inputsModalRef]); + + return ( + 0} + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + > +
+
inputsModalRef.current?.openModal()} + className="mx-auto max-w-[75%] w-fit bg-red-600 p-4 rounded-md shadow-lg z-50 cursor-pointer text-slate-200 items-center flex" + > + + Some project information might be missing. Click to see errors. +
+ + + +
+
+ ); + } +); + +export const useRouterHandleModalOpen = ( + searchParams: URLSearchParams, + setSearchParams: SetURLSearchParams +) => + useCallback(() => { + if (searchParams.get('show-error') === 'true') return; + setSearchParams( + (currentSearchParams) => { + currentSearchParams.set('show-error', 'true'); + return currentSearchParams; + }, + { replace: true, preventScrollReset: true } + ); + }, [setSearchParams, searchParams]); diff --git a/graph/shared/src/lib/project-graph-data-service/mock-project-graph-service.ts b/graph/shared/src/lib/project-graph-data-service/mock-project-graph-service.ts index ca18670f74cf5b..b992e2cafe59ed 100644 --- a/graph/shared/src/lib/project-graph-data-service/mock-project-graph-service.ts +++ b/graph/shared/src/lib/project-graph-data-service/mock-project-graph-service.ts @@ -61,6 +61,7 @@ export class MockProjectGraphService implements ProjectGraphService { focus: null, exclude: [], groupByFolder: false, + isPartial: false, }; private taskGraphsResponse: TaskGraphClientResponse = { diff --git a/graph/ui-components/src/index.ts b/graph/ui-components/src/index.ts index 82bc51d1ec3fe6..7ca48bebb04623 100644 --- a/graph/ui-components/src/index.ts +++ b/graph/ui-components/src/index.ts @@ -2,3 +2,5 @@ export * from './lib/debounced-text-input'; export * from './lib/tag'; export * from './lib/dropdown'; export * from './lib/spinner'; +export * from './lib/error-renderer'; +export * from './lib/modal'; diff --git a/graph/ui-components/src/lib/error-renderer.tsx b/graph/ui-components/src/lib/error-renderer.tsx new file mode 100644 index 00000000000000..cb02e0d111d402 --- /dev/null +++ b/graph/ui-components/src/lib/error-renderer.tsx @@ -0,0 +1,65 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ +export function ErrorRenderer({ errors }: { errors: GraphError[] }) { + return ( +
+ {errors.map((error, index) => { + let errorHeading; + if ( + error.fileName && + isCauseWithLocation(error.cause) && + error.cause.errors.length === 1 + ) { + errorHeading = `${error.name} - ${error.fileName}:${error.cause.errors[0].location.line}:${error.cause.errors[0].location.column} - ${error.pluginName}`; + } else if (error.fileName) { + errorHeading = `${error.fileName} - ${error.pluginName}`; + } else { + errorHeading = error.pluginName; + } + return ( +
+ + {errorHeading} + +
+              {isCauseWithErrors(error.cause) &&
+              error.cause.errors.length === 1 ? (
+                
+ {error.message}
+ {error.cause.errors[0].text}{' '} +
+ ) : ( +
{error.stack}
+ )} +
+
+ ); + })} +
+ ); +} + +function isCauseWithLocation(cause: unknown): cause is { + errors: { + location: { + column: number; + line: number; + }; + text: string; + }[]; +} { + return ( + isCauseWithErrors(cause) && + (cause as any).errors[0].location && + (cause as any).errors[0].location.column && + (cause as any).errors[0].location.line + ); +} + +function isCauseWithErrors( + cause: unknown +): cause is { errors: { text: string }[] } { + return cause && (cause as any).errors && (cause as any).errors[0].text; +} diff --git a/graph/ui-components/src/lib/modal.tsx b/graph/ui-components/src/lib/modal.tsx new file mode 100644 index 00000000000000..1b83f71996b709 --- /dev/null +++ b/graph/ui-components/src/lib/modal.tsx @@ -0,0 +1,108 @@ +// component from https://tailwindui.com/components/application-ui/overlays/dialogs +import { Dialog, Transition } from '@headlessui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { + ForwardedRef, + Fragment, + ReactNode, + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react'; + +export interface ModalProps { + children: ReactNode; + title: string; + onOpen?: () => void; + onClose?: () => void; +} + +export interface ModalHandle { + openModal: () => void; + closeModal: () => void; +} + +export const Modal = forwardRef( + ( + { children, title, onOpen, onClose }: ModalProps, + ref: ForwardedRef + ) => { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (open) { + onOpen?.(); + } else { + onClose?.(); + } + }, [open, onOpen, onClose]); + + useImperativeHandle(ref, () => ({ + closeModal: () => { + setOpen(false); + }, + openModal: () => { + setOpen(true); + }, + })); + + return ( + + + +
+ + +
+
+ + +
+ + {title} + + +
+
+ {children} +
+
+
+
+
+
+
+ ); + } +); diff --git a/graph/ui-project-details/src/lib/project-details/project-details.tsx b/graph/ui-project-details/src/lib/project-details/project-details.tsx index 27b0c4d75eb261..73f05e7cff8756 100644 --- a/graph/ui-project-details/src/lib/project-details/project-details.tsx +++ b/graph/ui-project-details/src/lib/project-details/project-details.tsx @@ -2,7 +2,10 @@ /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line -import { ProjectGraphProjectNode } from '@nx/devkit'; +import type { ProjectGraphProjectNode } from '@nx/devkit'; +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ import { EyeIcon } from '@heroicons/react/24/outline'; import { PropertyInfoTooltip, Tooltip } from '@nx/graph/ui-tooltips'; @@ -25,6 +28,7 @@ import { Pill } from '../pill'; export interface ProjectDetailsProps { project: ProjectGraphProjectNode; sourceMap: Record; + errors?: GraphError[]; variant?: 'default' | 'compact'; onTargetCollapse?: (targetName: string) => void; onTargetExpand?: (targetName: string) => void; diff --git a/packages/nx/src/command-line/graph/graph.ts b/packages/nx/src/command-line/graph/graph.ts index 6343e8b5662e09..4c5bc024cbdd0b 100644 --- a/packages/nx/src/command-line/graph/graph.ts +++ b/packages/nx/src/command-line/graph/graph.ts @@ -36,6 +36,8 @@ import { pruneExternalNodes } from '../../project-graph/operators'; import { createProjectGraphAndSourceMapsAsync, createProjectGraphAsync, + handleProjectGraphError, + ProjectGraphError, } from '../../project-graph/project-graph'; import { createTaskGraph, @@ -48,21 +50,34 @@ import { HashPlanner, transferProjectGraph } from '../../native'; import { transformProjectGraphForRust } from '../../native/transform-objects'; import { getAffectedGraphNodes } from '../affected/affected'; import { readFileMapCache } from '../../project-graph/nx-deps-cache'; +import { Hash, getNamedInputs } from '../../hasher/task-hasher'; import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; -import { filterUsingGlobPatterns } from '../../hasher/task-hasher'; import { createTaskHasher } from '../../hasher/create-task-hasher'; +import { filterUsingGlobPatterns } from '../../hasher/task-hasher'; + +export interface GraphError { + message: string; + stack: string; + cause: unknown; + name: string; + pluginName: string; + fileName?: string; +} + export interface ProjectGraphClientResponse { hash: string; projects: ProjectGraphProjectNode[]; dependencies: Record; - fileMap: ProjectFileMap; + fileMap?: ProjectFileMap; layout: { appsDir: string; libsDir: string }; affected: string[]; focus: string; groupByFolder: boolean; exclude: string[]; + isPartial: boolean; + errors?: GraphError[]; } export interface TaskGraphClientResponse { @@ -273,10 +288,30 @@ export async function generateGraph( ? args.targets[0] : args.targets; - const { projectGraph: rawGraph, sourceMaps } = - await createProjectGraphAndSourceMapsAsync({ - exitOnError: true, - }); + let rawGraph: ProjectGraph; + let sourceMaps: ConfigurationSourceMaps; + let isPartial = false; + try { + const projectGraphAndSourceMaps = + await createProjectGraphAndSourceMapsAsync({ + exitOnError: false, + }); + rawGraph = projectGraphAndSourceMaps.projectGraph; + sourceMaps = projectGraphAndSourceMaps.sourceMaps; + } catch (e) { + if (e instanceof ProjectGraphError) { + output.warn({ + title: 'Failed to process project graph. Showing partial graph.', + }); + rawGraph = e.getPartialProjectGraph(); + sourceMaps = e.getPartialSourcemaps(); + + isPartial = true; + } + if (!rawGraph) { + handleProjectGraphError({ exitOnError: true }, e); + } + } let prunedGraph = pruneExternalNodes(rawGraph); const projects = Object.values( @@ -632,6 +667,8 @@ let currentProjectGraphClientResponse: ProjectGraphClientResponse = { focus: null, groupByFolder: false, exclude: [], + isPartial: false, + errors: [], }; let currentSourceMapsClientResponse: ConfigurationSourceMaps = {}; @@ -649,7 +686,11 @@ function debounce(fn: (...args) => void, time: number) { function createFileWatcher() { return daemonClient.registerFileWatcher( - { watchProjects: 'all', includeGlobalWorkspaceFiles: true }, + { + watchProjects: 'all', + includeGlobalWorkspaceFiles: true, + allowPartialGraph: true, + }, debounce(async (error, changes) => { if (error === 'closed') { output.error({ title: `Watch error: Daemon closed the connection` }); @@ -687,11 +728,39 @@ async function createProjectGraphAndSourceMapClientResponse( }> { performance.mark('project graph watch calculation:start'); - const { projectGraph, sourceMaps } = - await createProjectGraphAndSourceMapsAsync({ exitOnError: true }); + let projectGraph: ProjectGraph; + let sourceMaps: ConfigurationSourceMaps; + let isPartial = false; + let errors: GraphError[] | undefined; + try { + const projectGraphAndSourceMaps = + await createProjectGraphAndSourceMapsAsync({ exitOnError: false }); + projectGraph = projectGraphAndSourceMaps.projectGraph; + sourceMaps = projectGraphAndSourceMaps.sourceMaps; + } catch (e) { + if (e instanceof ProjectGraphError) { + projectGraph = e.getPartialProjectGraph(); + sourceMaps = e.getPartialSourcemaps(); + errors = e.getErrors().map((e) => ({ + message: e.message, + stack: e.stack, + cause: e.cause, + name: e.name, + pluginName: (e as any).pluginName, + fileName: + (e as any).file ?? (e.cause as any)?.errors?.[0]?.location?.file, + })); + isPartial = true; + } + + if (!projectGraph) { + handleProjectGraphError({ exitOnError: true }, e); + } + } let graph = pruneExternalNodes(projectGraph); - let fileMap = readFileMapCache().fileMap.projectFileMap; + let fileMap: ProjectFileMap | undefined = + readFileMapCache()?.fileMap.projectFileMap; performance.mark('project graph watch calculation:end'); performance.mark('project graph response generation:start'); @@ -727,6 +796,8 @@ async function createProjectGraphAndSourceMapClientResponse( dependencies, affected, fileMap, + isPartial, + errors, }, sourceMapResponse: sourceMaps, }; @@ -736,12 +807,15 @@ async function createTaskGraphClientResponse( pruneExternal: boolean = false ): Promise { let graph: ProjectGraph; + try { + graph = await createProjectGraphAsync({ exitOnError: false }); + } catch (e) { + if (e instanceof ProjectGraphError) { + graph = e.getPartialProjectGraph(); + } + } if (pruneExternal) { - graph = pruneExternalNodes( - await createProjectGraphAsync({ exitOnError: true }) - ); - } else { - graph = await createProjectGraphAsync({ exitOnError: true }); + graph = pruneExternalNodes(graph); } const nxJson = readNxJson(); diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index a2c6771853009f..e94bbb069edd5e 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -171,6 +171,7 @@ export class DaemonClient { watchProjects: string[] | 'all'; includeGlobalWorkspaceFiles?: boolean; includeDependentProjects?: boolean; + allowPartialGraph?: boolean; }, callback: ( error: Error | null | 'closed', @@ -180,7 +181,15 @@ export class DaemonClient { } | null ) => void ): Promise { - await this.getProjectGraphAndSourceMaps(); + try { + await this.getProjectGraphAndSourceMaps(); + } catch (e) { + if (config.allowPartialGraph && e instanceof ProjectGraphError) { + // we are fine with partial graph + } else { + throw e; + } + } let messenger: DaemonSocketMessenger | undefined; await this.queue.sendToQueue(() => { diff --git a/packages/nx/src/daemon/server/file-watching/file-watcher-sockets.ts b/packages/nx/src/daemon/server/file-watching/file-watcher-sockets.ts index bc1f64fde45a85..453010d913bc9a 100644 --- a/packages/nx/src/daemon/server/file-watching/file-watcher-sockets.ts +++ b/packages/nx/src/daemon/server/file-watching/file-watcher-sockets.ts @@ -1,6 +1,5 @@ import { Socket } from 'net'; import { findMatchingProjects } from '../../../utils/find-matching-projects'; -import { ProjectGraph } from '../../../config/project-graph'; import { findAllProjectNodeDependencies } from '../../../utils/project-graph-utils'; import { PromisedBasedQueue } from '../../../utils/promised-based-queue'; import { currentProjectGraph } from '../project-graph-incremental-recomputation'; diff --git a/packages/nx/src/plugins/project-json/build-nodes/project-json.ts b/packages/nx/src/plugins/project-json/build-nodes/project-json.ts index 9dfc44fcbc262e..14c6c5acf9a65c 100644 --- a/packages/nx/src/plugins/project-json/build-nodes/project-json.ts +++ b/packages/nx/src/plugins/project-json/build-nodes/project-json.ts @@ -4,15 +4,16 @@ import { ProjectConfiguration } from '../../../config/workspace-json-project-jso import { toProjectName } from '../../../config/workspaces'; import { readJsonFile } from '../../../utils/fileutils'; import { NxPluginV2 } from '../../../project-graph/plugins'; +import { CreateNodesError } from '../../../project-graph/error-types'; export const ProjectJsonProjectsPlugin: NxPluginV2 = { name: 'nx/core/project-json', createNodes: [ '{project.json,**/project.json}', (file, _, { workspaceRoot }) => { - const json = readJsonFile( - join(workspaceRoot, file) - ); + let json: ProjectConfiguration; + json = readJsonFile(join(workspaceRoot, file)); + const project = buildProjectFromProjectJson(json, file); return { projects: { diff --git a/packages/nx/src/project-graph/plugins/utils.ts b/packages/nx/src/project-graph/plugins/utils.ts index 62730b151d6e0f..59031ad10b7efd 100644 --- a/packages/nx/src/project-graph/plugins/utils.ts +++ b/packages/nx/src/project-graph/plugins/utils.ts @@ -9,7 +9,12 @@ import type { LoadedNxPlugin, NormalizedPlugin, } from './internal-api'; -import type { CreateNodesContext, NxPlugin, NxPluginV2 } from './public-api'; +import { + CreateNodesResult, + type CreateNodesContext, + type NxPlugin, + type NxPluginV2, +} from './public-api'; import { AggregateCreateNodesError, CreateNodesError } from '../error-types'; export function isNxPluginV2(plugin: NxPlugin): plugin is NxPluginV2 { @@ -61,11 +66,17 @@ export async function runCreateNodesInParallel( const promises: Array> = configFiles.map((file) => { performance.mark(`${plugin.name}:createNodes:${file} - start`); - // Result is either static or a promise, using Promise.resolve lets us - // handle both cases with same logic - const value = Promise.resolve( - plugin.createNodes[1](file, options, context) - ); + + const value = new Promise((resolve, reject) => { + try { + Promise.resolve(plugin.createNodes[1](file, options, context)) + .then(resolve) + .catch(reject); + } catch (e) { + reject(e); + } + }); + return value .catch((e) => { performance.mark(`${plugin.name}:createNodes:${file} - end`); diff --git a/packages/nx/src/project-graph/project-graph.ts b/packages/nx/src/project-graph/project-graph.ts index 5fa8035661aedc..b6a9b3df48e1ef 100644 --- a/packages/nx/src/project-graph/project-graph.ts +++ b/packages/nx/src/project-graph/project-graph.ts @@ -240,7 +240,7 @@ export class ProjectGraphError extends Error { } } -function handleProjectGraphError(opts: { exitOnError: boolean }, e) { +export function handleProjectGraphError(opts: { exitOnError: boolean }, e) { if (opts.exitOnError) { const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true'; if (e instanceof ProjectGraphError) {