Repository: Automattic/studio
Pull request #1064: Custom Domain: Allow updating domains for existing sites.
Author: login: youknowriad
Created: 2025-03-13T12:42:57Z | Merged: 2025-03-20T12:13:41Z

## Related issues

- Fixes #1053 
- Fixes STU-72-linear-issue

## Summary

  - Adds ability to add, modify, or remove custom domains on existing sites
  - Ensures consistent domain validation and handling across site creation and editing
  - Ensures we update the hosts file and the database content when the domain is updated.

## Testing instructions

 - Create a site (once with a custom domain, and once without)
 - Try updating domains for running and stopped sites

Linked issue #1053: Feature Request: Allow modifying the custom domain of a given site
### What

We recently introduce the possibility to assign a custom local domain to a given site created in Studio.
That said, it is not possible right now to edit the domain for an existing site.

In the same way, we're introducing SSL support #1034 on site creation but not edition.


### Why

A small technical limitation is preventing that, it seems common sense to remove this limitation. 

### How


If we want to allow editing these features, we would have to trigger a configuration change in WordPress and update the database maybe by running a WP CLI command

```
wp search-replace 'http://example.local' 'https://example2.local'
```

I believe this logic might already be present in Studio for push/pull support, so let's try to use the same logic/code.

Diff:
diff --git a/src/components/add-site.tsx b/src/components/add-site.tsx
index 0952423b99..2b75c24b28 100644
--- a/src/components/add-site.tsx
+++ b/src/components/add-site.tsx
@@ -206,7 +206,9 @@ export default function AddSite( { className }: AddSiteProps ) {
 								<Button
 									type="submit"
 									variant="primary"
-									disabled={ !! error || !! customDomainError || ! siteName?.trim() }
+									disabled={
+										!! error || ( !! customDomainError && useCustomDomain ) || ! siteName?.trim()
+									}
 								>
 									{ __( 'Add site' ) }
 								</Button>
diff --git a/src/components/site-form.tsx b/src/components/site-form.tsx
index 2c5c28a0e5..cb9cc47356 100644
--- a/src/components/site-form.tsx
+++ b/src/components/site-form.tsx
@@ -13,7 +13,7 @@ import { ACCEPTED_IMPORT_FILE_TYPES } from 'src/constants';
 import { useDocsLink } from 'src/hooks/use-docs-link';
 import { useOffline } from 'src/hooks/use-offline';
 import { cx } from 'src/lib/cx';
-import { generateCustomDomainFromSiteName } from 'src/lib/generate-custom-domain';
+import { generateCustomDomainFromSiteName } from 'src/lib/domains';
 import { getIpcApi } from 'src/lib/get-ipc-api';
 import { useAppDispatch, useRootSelector } from 'src/stores';
 import {
@@ -294,6 +294,7 @@ export const SiteForm = ( {
 		chevronIcon = chevronRight;
 	}
 	const generatedDomainName = generateCustomDomainFromSiteName( siteName );
+	const shouldShowCustomDomainError = customDomainError && useCustomDomain;
 
 	return (
 		<form className={ className } onSubmit={ onSubmit }>
@@ -353,10 +354,10 @@ export const SiteForm = ( {
 											{ __( 'Advanced settings' ) }
 										</div>
 									</Button>
-									{ ( error || customDomainError ) && (
+									{ ( error || shouldShowCustomDomainError ) && (
 										<span className="text-red-500 text-[13px] leading-[16px] ml-2 flex items-center">
 											<Icon icon={ warning } size={ 16 } className="mr-1 fill-red-500" />
-											{ error && customDomainError
+											{ error && shouldShowCustomDomainError
 												? __( '2 errors found' )
 												: __( '1 error found' ) }
 										</span>
diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts
index ee25906868..1d1eacd270 100644
--- a/src/hooks/use-add-site.ts
+++ b/src/hooks/use-add-site.ts
@@ -3,7 +3,7 @@ import { useI18n } from '@wordpress/react-i18n';
 import { useCallback, useMemo, useState } from 'react';
 import { useImportExport } from 'src/hooks/use-import-export';
 import { useSiteDetails } from 'src/hooks/use-site-details';
-import { generateCustomDomainFromSiteName } from 'src/lib/generate-custom-domain';
+import { generateCustomDomainFromSiteName, validateDomainName } from 'src/lib/domains';
 import { getIpcApi } from 'src/lib/get-ipc-api';
 import {
 	DEFAULT_PHP_VERSION,
@@ -39,20 +39,9 @@ export function useAddSite() {
 	const handleCustomDomainChange = useCallback(
 		( value: string | null ) => {
 			setCustomDomain( value );
-			// Validate custom domain if enabled
-			const domainPattern =
-				/^(?!-)[\p{L}\p{N}][\p{L}\p{N}-]{0,61}[\p{L}\p{N}](?<!-)(?:\.(?!-)[\p{L}\p{N}-]{1,61}[\p{L}\p{N}](?<!-))+$/u;
-			if ( useCustomDomain && value && ! domainPattern.test( value ) ) {
-				setCustomDomainError( __( 'Please enter a valid domain name' ) );
-			} else if ( useCustomDomain && value && value.length > 253 ) {
-				setCustomDomainError( __( 'The domain name is too long' ) );
-			} else if ( useCustomDomain && value === '' ) {
-				setCustomDomainError( __( 'The domain name is required' ) );
-			} else {
-				setCustomDomainError( '' );
-			}
+			setCustomDomainError( validateDomainName( useCustomDomain, value ) );
 		},
-		[ __, useCustomDomain, setCustomDomain, setCustomDomainError ]
+		[ useCustomDomain, setCustomDomain, setCustomDomainError ]
 	);
 
 	const handlePathSelectorClick = useCallback( async () => {
diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts
index fe832a4afa..61cf31c66d 100644
--- a/src/ipc-handlers.ts
+++ b/src/ipc-handlers.ts
@@ -27,7 +27,7 @@ import { download } from 'src/lib/download';
 import { isEmptyDir, pathExists, isWordPressDirectory, sanitizeFolderName } from 'src/lib/fs-utils';
 import { getImageData } from 'src/lib/get-image-data';
 import { getSyncBackupTempPath } from 'src/lib/get-sync-backup-temp-path';
-import { addDomainToHosts } from 'src/lib/hosts-file';
+import { addDomainToHosts, updateDomainInHosts } from 'src/lib/hosts-file';
 import { exportBackup } from 'src/lib/import-export/export/export-manager';
 import { ExportOptions } from 'src/lib/import-export/export/types';
 import { ImportExportEventData } from 'src/lib/import-export/handle-events';
@@ -45,7 +45,7 @@ import { portFinder } from 'src/lib/port-finder';
 import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper';
 import { sortSites } from 'src/lib/sort-sites';
 import { installSqliteIntegration, keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions';
-import { updateSiteUrlToLocal } from 'src/lib/updateSiteUrlToLocal';
+import { updateSiteUrlToLocal } from 'src/lib/update-site-url-to-local';
 import * as windowsHelpers from 'src/lib/windows-helpers';
 import { getLogsFilePath, writeLogToFile, type LogLevel } from 'src/logging';
 import { getMainWindow } from 'src/main-window';
@@ -220,6 +220,10 @@ export async function updateSite(
 	updatedSite: SiteDetails
 ): Promise< SiteDetails[] > {
 	const userData = await loadUserData();
+
+	const existingSite = userData.sites.find( ( site ) => site.id === updatedSite.id );
+	const oldDomain = existingSite?.customDomain;
+	const newDomain = updatedSite.customDomain;
 	const updatedSites = userData.sites.map( ( site ) =>
 		site.id === updatedSite.id ? updatedSite : site
 	);
@@ -228,6 +232,12 @@ export async function updateSite(
 	const server = SiteServer.get( updatedSite.id );
 	if ( server ) {
 		server.updateSiteDetails( updatedSite );
+
+		// Handle domain changes if the site is running (updates hosts and database)
+		if ( oldDomain !== newDomain ) {
+			updateDomainInHosts( oldDomain, newDomain, server.details.port );
+			await updateSiteUrlToLocal( updatedSite.id );
+		}
 	}
 	await saveUserData( userData );
 	return mergeSiteDetailsWithRunningDetails( userData.sites );
diff --git a/src/lib/domains.ts b/src/lib/domains.ts
new file mode 100644
index 0000000000..52e1755a35
--- /dev/null
+++ b/src/lib/domains.ts
@@ -0,0 +1,33 @@
+import { __ } from '@wordpress/i18n';
+import { sanitizeFolderName } from './generate-site-name';
+
+/**
+ * Generates a suitable domain name from site name
+ */
+export const generateCustomDomainFromSiteName = ( siteName: string ): string => {
+	const domainBase = sanitizeFolderName( siteName );
+
+	return `${ domainBase }.wp.local`;
+};
+
+export const validateDomainName = (
+	useCustomDomain: boolean,
+	domainName: string | null
+): string => {
+	// Validate custom domain if enabled
+	const domainPattern =
+		/^(?!-)[\p{L}\p{N}][\p{L}\p{N}-]{0,61}[\p{L}\p{N}](?<!-)(?:\.(?!-)[\p{L}\p{N}-]{1,61}[\p{L}\p{N}](?<!-))+$/u;
+	if ( useCustomDomain && domainName && ! domainPattern.test( domainName ) ) {
+		return __( 'Please enter a valid domain name' );
+	}
+
+	if ( useCustomDomain && domainName && domainName.length > 253 ) {
+		return __( 'The domain name is too long' );
+	}
+
+	if ( useCustomDomain && domainName === '' ) {
+		return __( 'The domain name is required' );
+	}
+
+	return '';
+};
diff --git a/src/lib/generate-custom-domain.ts b/src/lib/generate-custom-domain.ts
deleted file mode 100644
index ba00f64279..0000000000
--- a/src/lib/generate-custom-domain.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { sanitizeFolderName } from './generate-site-name';
-
-/**
- * Generates a suitable domain name from site name
- */
-export const generateCustomDomainFromSiteName = ( siteName: string ): string => {
-	const domainBase = sanitizeFolderName( siteName );
-
-	return `${ domainBase }.wp.local`;
-};
diff --git a/src/lib/hosts-file.ts b/src/lib/hosts-file.ts
index c737824b33..a8ca7ca6ac 100644
--- a/src/lib/hosts-file.ts
+++ b/src/lib/hosts-file.ts
@@ -127,6 +127,48 @@ export const removeDomainFromHosts = async ( domain: string ): Promise< void > =
 	}
 };
 
+export const updateDomainInHosts = async (
+	oldDomain: string | undefined,
+	newDomain: string | undefined,
+	port: number
+): Promise< void > => {
+	if ( oldDomain === newDomain ) {
+		return;
+	}
+
+	if ( ! oldDomain && newDomain ) {
+		await addDomainToHosts( newDomain, port );
+		return;
+	}
+
+	if ( oldDomain && ! newDomain ) {
+		await removeDomainFromHosts( oldDomain );
+		return;
+	}
+
+	try {
+		const hostsContent = await readHostsFile();
+		const encodedOldDomain = domainToASCII( oldDomain as string );
+		const encodedNewDomain = domainToASCII( newDomain as string );
+		const oldPattern = createHostsEntryPattern( encodedOldDomain );
+		const newContent = updateStudioBlock( hostsContent, ( entries ) => {
+			const filtered = entries.filter( ( entry ) => ! entry.match( oldPattern ) );
+			return [ ...filtered, `127.0.0.1 ${ encodedNewDomain } # Port ${ port }` ];
+		} );
+
+		if ( newContent !== hostsContent ) {
+			await writeHostsFile( newContent );
+		}
+	} catch ( error ) {
+		Sentry.captureException( error );
+		console.error(
+			`Error replacing domain ${ oldDomain } with ${ newDomain } in hosts file:`,
+			error
+		);
+		throw error;
+	}
+};
+
 /**
  * Helper function for manipulating the "block" of entries in the hosts file
  * pertaining to WordPres Studio.
diff --git a/src/lib/import-export/import/importers/importer.ts b/src/lib/import-export/import/importers/importer.ts
index dab5b7daa4..0ac0f65ab8 100644
--- a/src/lib/import-export/import/importers/importer.ts
+++ b/src/lib/import-export/import/importers/importer.ts
@@ -11,7 +11,7 @@ import { generateBackupFilename } from 'src/lib/import-export/export/generate-ba
 import { ImportEvents } from 'src/lib/import-export/import/events';
 import { BackupContents, MetaFileData } from 'src/lib/import-export/import/types';
 import { serializePlugins } from 'src/lib/serialize-plugins';
-import { updateSiteUrlToLocal } from 'src/lib/updateSiteUrlToLocal';
+import { updateSiteUrlToLocal } from 'src/lib/update-site-url-to-local';
 import { SiteServer } from 'src/site-server';
 import { DEFAULT_PHP_VERSION } from 'vendor/wp-now/src/constants';
 
diff --git a/src/lib/tests/hosts-file.test.ts b/src/lib/tests/hosts-file.test.ts
index 610e73e855..974eb9aa82 100644
--- a/src/lib/tests/hosts-file.test.ts
+++ b/src/lib/tests/hosts-file.test.ts
@@ -1,5 +1,5 @@
 import { readFile, writeFile } from 'fs';
-import { addDomainToHosts, removeDomainFromHosts } from '../hosts-file';
+import { addDomainToHosts, removeDomainFromHosts, updateDomainInHosts } from '../hosts-file';
 
 const readFileCallbackMock = jest.fn();
 
@@ -191,4 +191,93 @@ describe( 'hosts-file', () => {
 			await expect( removeDomainFromHosts( 'test.wp.cloud' ) ).rejects.toThrow( 'Read error' );
 		} );
 	} );
+
+	describe( 'updateDomainInHosts', () => {
+		it( 'should replace an existing domain with a new domain', async () => {
+			readFileCallbackMock.mockResolvedValueOnce( sampleHostsContent );
+
+			await updateDomainInHosts( 'foo.wp.cloud', 'new-domain.wp.cloud', 8002 );
+
+			expect( readFile ).toHaveBeenCalled();
+			expect( writeFile ).toHaveBeenCalled();
+
+			const newContent = ( writeFile as unknown as jest.Mock ).mock.calls[ 0 ][ 1 ];
+
+			expect( newContent ).toEqual(
+				`127.0.0.1 localhost
+::1 localhost
+
+# Some comment
+
+# BEGIN WordPress Studio
+127.0.0.1 bar.wp.cloud # Port 8001
+127.0.0.1 new-domain.wp.cloud # Port 8002
+# END WordPress Studio
+
+# Other entries
+192.168.1.1 router`
+			);
+		} );
+
+		it( 'should add a new domain if old domain is undefined', async () => {
+			readFileCallbackMock.mockResolvedValueOnce( sampleHostsContent );
+
+			await updateDomainInHosts( undefined, 'new-domain.wp.cloud', 8002 );
+
+			expect( readFile ).toHaveBeenCalled();
+			expect( writeFile ).toHaveBeenCalled();
+
+			const newContent = ( writeFile as unknown as jest.Mock ).mock.calls[ 0 ][ 1 ];
+
+			expect( newContent ).toEqual(
+				`127.0.0.1 localhost
+::1 localhost
+
+# Some comment
+
+# BEGIN WordPress Studio
+127.0.0.1 foo.wp.cloud # Port 8000
+127.0.0.1 bar.wp.cloud # Port 8001
+127.0.0.1 new-domain.wp.cloud # Port 8002
+# END WordPress Studio
+
+# Other entries
+192.168.1.1 router`
+			);
+		} );
+
+		it( 'should remove the old domain if new domain is undefined', async () => {
+			readFileCallbackMock.mockResolvedValueOnce( sampleHostsContent );
+
+			await updateDomainInHosts( 'foo.wp.cloud', undefined, 8000 );
+
+			expect( readFile ).toHaveBeenCalled();
+			expect( writeFile ).toHaveBeenCalled();
+
+			const newContent = ( writeFile as unknown as jest.Mock ).mock.calls[ 0 ][ 1 ];
+
+			expect( newContent ).toEqual(
+				`127.0.0.1 localhost
+::1 localhost
+
+# Some comment
+
+# BEGIN WordPress Studio
+127.0.0.1 bar.wp.cloud # Port 8001
+# END WordPress Studio
+
+# Other entries
+192.168.1.1 router`
+			);
+		} );
+
+		it( 'should not modify the hosts file if old and new domains are the same', async () => {
+			readFileCallbackMock.mockResolvedValueOnce( sampleHostsContent );
+
+			await updateDomainInHosts( 'foo.wp.cloud', 'foo.wp.cloud', 8000 );
+
+			expect( readFile ).not.toHaveBeenCalled();
+			expect( writeFile ).not.toHaveBeenCalled();
+		} );
+	} );
 } );
diff --git a/src/lib/updateSiteUrlToLocal.ts b/src/lib/update-site-url-to-local.ts
similarity index 94%
rename from src/lib/updateSiteUrlToLocal.ts
rename to src/lib/update-site-url-to-local.ts
index 10bd750f89..e539fe929c 100644
--- a/src/lib/updateSiteUrlToLocal.ts
+++ b/src/lib/update-site-url-to-local.ts
@@ -12,12 +12,15 @@ export const updateSiteUrlToLocal = async ( siteId: string ) => {
 	} );
 
 	if ( ! currentSiteUrl ) {
-		console.error( 'Failed to fetch site URL after import' );
+		console.error( 'Failed to fetch site URL' );
 		return;
 	}
 
 	const studioUrl = getSiteUrl( server.details );
 	const oldUrl = currentSiteUrl.trim();
+	if ( studioUrl === oldUrl ) {
+		return;
+	}
 	const urlWithoutProtocol = oldUrl.replace( /^https?:\/\//, '' );
 
 	const oldUrlVariants = [
diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx
index a5786f1f07..8126393d0b 100644
--- a/src/modules/site-settings/edit-site-details.tsx
+++ b/src/modules/site-settings/edit-site-details.tsx
@@ -11,6 +11,7 @@ import { Tooltip } from 'src/components/tooltip';
 import { useOffline } from 'src/hooks/use-offline';
 import { useSiteDetails } from 'src/hooks/use-site-details';
 import { cx } from 'src/lib/cx';
+import { generateCustomDomainFromSiteName, validateDomainName } from 'src/lib/domains';
 import { getIpcApi } from 'src/lib/get-ipc-api';
 import { getWordPressVersionUrl } from 'src/lib/get-wordpress-version-url';
 import { useRootSelector } from 'src/stores';
@@ -45,6 +46,11 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 		( selectedSite?.phpVersion as AllowedPHPVersion ) ?? DEFAULT_PHP_VERSION
 	);
 	const [ selectedWpVersion, setSelectedWpVersion ] = useState( currentWpVersion );
+	const [ useCustomDomain, setUseCustomDomain ] = useState( Boolean( selectedSite?.customDomain ) );
+	const [ customDomain, setCustomDomain ] = useState< string | null >(
+		selectedSite?.customDomain ?? null
+	);
+	const [ customDomainError, setCustomDomainError ] = useState( '' );
 	const wordpressVersions = useRootSelector(
 		wordpressVersionsSelectors.selectWordPressVersionsWithLatest
 	);
@@ -55,6 +61,17 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 	if ( ! wordpressVersionOptions.some( ( version ) => version.value === currentWpVersion ) ) {
 		wordpressVersionOptions.push( { label: currentWpVersion, value: currentWpVersion } );
 	}
+	const generatedDomainName = generateCustomDomainFromSiteName( siteName );
+	const isFormUnchanged =
+		!! selectedSite &&
+		selectedSite.name === siteName &&
+		selectedSite.phpVersion === selectedPhpVersion &&
+		currentWpVersion === selectedWpVersion &&
+		Boolean( selectedSite.customDomain ) === useCustomDomain &&
+		( ! useCustomDomain || selectedSite.customDomain === customDomain );
+
+	const hasValidationErrors =
+		! selectedSite || ! siteName.trim() || ( useCustomDomain && !! customDomainError );
 
 	const resetFormState = useCallback( () => {
 		if ( ! selectedSite ) {
@@ -63,6 +80,9 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 		setSiteName( selectedSite.name );
 		setSelectedPhpVersion( selectedSite.phpVersion as AllowedPHPVersion );
 		setSelectedWpVersion( currentWpVersion );
+		setUseCustomDomain( Boolean( selectedSite.customDomain ) );
+		setCustomDomain( selectedSite.customDomain ?? null );
+		setCustomDomainError( '' );
 		setIsChangeWpError( '' );
 	}, [ currentWpVersion, selectedSite ] );
 
@@ -108,10 +128,17 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 				}
 			}
 
+			// Determine custom domain setting
+			let usedCustomDomain = useCustomDomain && customDomain ? customDomain : undefined;
+			if ( useCustomDomain && ! customDomain ) {
+				usedCustomDomain = generateCustomDomainFromSiteName( siteName ?? '' );
+			}
+
 			await updateSite( {
 				...selectedSite,
 				name: siteName,
 				phpVersion: selectedPhpVersion,
+				customDomain: usedCustomDomain,
 			} );
 
 			if ( needsRestart ) {
@@ -126,6 +153,14 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 		setIsEditingSite( false );
 	};
 
+	const handleCustomDomainChange = useCallback(
+		( value: string | null ) => {
+			setCustomDomain( value );
+			setCustomDomainError( validateDomainName( useCustomDomain, value ) );
+		},
+		[ useCustomDomain, setCustomDomain, setCustomDomainError ]
+	);
+
 	return (
 		<>
 			{ showModal && (
@@ -199,6 +234,39 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 							{ isChangeWpError && (
 								<ErrorInformation className="mt-2">{ isChangeWpError }</ErrorInformation>
 							) }
+
+							<div className="flex flex-col gap-2 mt-4">
+								<div className="flex items-center gap-2">
+									<input
+										type="checkbox"
+										id="use-custom-domain"
+										checked={ useCustomDomain }
+										onChange={ ( e ) => setUseCustomDomain( e.target.checked ) }
+										disabled={ isEditingSite }
+									/>
+									<label htmlFor="use-custom-domain">{ __( 'Use custom domain' ) }</label>
+								</div>
+
+								{ useCustomDomain && (
+									<div className="flex flex-col gap-2 mt-2">
+										<label htmlFor="custom-domain" className="font-semibold">
+											{ __( 'Domain name' ) }
+										</label>
+										<TextControlComponent
+											id="custom-domain"
+											value={ customDomain ?? generatedDomainName }
+											onChange={ handleCustomDomainChange }
+											disabled={ isEditingSite }
+										/>
+										{ customDomainError && (
+											<ErrorInformation className="mt-1">{ customDomainError }</ErrorInformation>
+										) }
+										<div className="text-a8c-gray-50 text-xs mt-1">
+											{ __( 'Your system password will be required to set up the domain.' ) }
+										</div>
+									</div>
+								) }
+							</div>
 						</div>
 
 						<div className="flex flex-row justify-end gap-x-5 mt-8">
@@ -209,14 +277,7 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 								type="submit"
 								variant="primary"
 								isBusy={ isEditingSite }
-								disabled={ Boolean(
-									isEditingSite ||
-										! selectedSite ||
-										( selectedSite?.name === siteName &&
-											selectedSite?.phpVersion === selectedPhpVersion &&
-											currentWpVersion === selectedWpVersion ) ||
-										! siteName.trim()
-								) }
+								disabled={ isEditingSite || isFormUnchanged || hasValidationErrors }
 							>
 								{ isEditingSite ? __( 'Saving…' ) : __( 'Save' ) }
 							</Button>
diff --git a/src/site-server.ts b/src/site-server.ts
index b1fc754b23..4c15524734 100644
--- a/src/site-server.ts
+++ b/src/site-server.ts
@@ -57,6 +57,12 @@ export async function stopAllServersOnQuit() {
 	await Promise.all( [ ...servers.values() ].map( ( server ) => server.server?.stop() ) );
 }
 
+function getAbsoluteUrl( details: SiteDetails ): string {
+	return details.customDomain
+		? `http://${ details.customDomain }`
+		: `http://localhost:${ details.port }`;
+}
+
 export class SiteServer {
 	server?: SiteServerProcess;
 	wpCliExecutor?: WpCliProcess;
@@ -112,17 +118,8 @@ export class SiteServer {
 			siteTitle: this.details.name,
 			php: this.details.phpVersion,
 		} );
-		// Determine the URL to use - either custom domain or localhost with port
-		let absoluteUrl;
-		if ( this.details.customDomain ) {
-			absoluteUrl = `http://${ this.details.customDomain }`;
-			// For custom domains, we still need to handle hosts file management elsewhere
-			// This happens in the ipc-handlers.ts file when starting the server
-		} else {
-			absoluteUrl = `http://localhost:${ this.details.port }`;
-		}
 
-		options.absoluteUrl = absoluteUrl;
+		options.absoluteUrl = getAbsoluteUrl( this.details );
 		options.siteLanguage = await getPreferredSiteLanguage( options.wordPressVersion );
 
 		if ( options.mode !== WPNowMode.WORDPRESS ) {
@@ -152,16 +149,19 @@ export class SiteServer {
 	}
 
 	updateSiteDetails( site: SiteDetails ) {
-		// We no longer modify custom domain settings after site creation,
-		// so we preserve the existing custom domain settings and only update other fields
 		this.details = {
 			...this.details,
 			name: site.name,
 			path: site.path,
 			phpVersion: site.phpVersion,
 			wpVersion: site.wpVersion,
-			customDomain: this.details.customDomain,
+			customDomain: site.customDomain,
 		};
+
+		if ( this.server && this.details.running ) {
+			this.details.url = getAbsoluteUrl( this.details );
+			this.server.url = this.details.url;
+		}
 	}
 
 	async stop() {


Reviews:
user: login: bcotrim
body: Great feature!
Added some comments, but overall I think this looks great!

Something I noticed during my tests:
- Having an invalid domain disables the submit, even when "Use custom domain" isn't checked. I recorded on "Edit Site" but the same behavior happens on "Add Site":


https://github.com/user-attachments/assets/6970cb47-2f42-4374-898c-8ba299d2705c
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T19:22:20Z
user: login: youknowriad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T19:25:28Z
user: login: youknowriad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T06:39:45Z
user: login: bcotrim
body: Thanks for making the changes.
I added a small comment, but it shouldn't be blocking.

I noticed the custom domain error message get cut when we display multiple errors.
This should be fixed with [this change](https://github.com/Automattic/studio/pull/1078/files#diff-e2be6df548f75063abd8db0f241c35626f7af6a1c6a10c429935de5f902dbbc6R373) but something to re-test later.

![image](https://github.com/user-attachments/assets/45919701-18e7-498a-bfcd-600ddaef625b)

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T10:36:02Z
user: login: youknowriad
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:07:05Z

Review comments:
diff_hunk: @@ -127,6 +127,48 @@ export const removeDomainFromHosts = async ( domain: string ): Promise< void > =
 	}
 };
 
+export const replaceDomainInHosts = async (
path: src/lib/hosts-file.ts
user: login: bcotrim
body: I would rename this function as it handles all possible domain change scenarios.

And to keep the format consistent, move the replacing logic to its own `replaceDomainInHosts`
created_at: 2025-03-19T18:51:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,12 +12,15 @@ export const updateSiteUrlToLocal = async ( siteId: string ) => {
 	} );
 
 	if ( ! currentSiteUrl ) {
-		console.error( 'Failed to fetch site URL after import' );
+		console.error( 'Failed to fetch site URL' );
path: src/lib/updateSiteUrlToLocal.ts
user: login: bcotrim
body: Could we rename this file to follow kebab-case format as the other files in the project?
created_at: 2025-03-19T19:10:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,6 +46,11 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 		( selectedSite?.phpVersion as AllowedPHPVersion ) ?? DEFAULT_PHP_VERSION
 	);
 	const [ selectedWpVersion, setSelectedWpVersion ] = useState( currentWpVersion );
+	const [ useCustomDomain, setUseCustomDomain ] = useState( Boolean( selectedSite?.customDomain ) );
path: src/modules/site-settings/edit-site-details.tsx
user: login: bcotrim
body: It seems a lot of this logic is very similar to the one we added to `site-form`
I believe there's an opportunity here to create a shared component/hook, to avoid repetition.
created_at: 2025-03-19T19:19:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -45,6 +46,11 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 		( selectedSite?.phpVersion as AllowedPHPVersion ) ?? DEFAULT_PHP_VERSION
 	);
 	const [ selectedWpVersion, setSelectedWpVersion ] = useState( currentWpVersion );
+	const [ useCustomDomain, setUseCustomDomain ] = useState( Boolean( selectedSite?.customDomain ) );
path: src/modules/site-settings/edit-site-details.tsx
user: login: youknowriad
body: Entirely agree but this is not new. The way the form works today is not great and would deserve a refactor but it's not a small change. 
created_at: 2025-03-19T19:25:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,6 +127,48 @@ export const removeDomainFromHosts = async ( domain: string ): Promise< void > =
 	}
 };
 
+export const replaceDomainInHosts = async (
path: src/lib/hosts-file.ts
user: login: youknowriad
body: Not sure I understand this comment fully but I renamed the function. 
created_at: 2025-03-20T06:39:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,8 +272,11 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 										! selectedSite ||
 										( selectedSite?.name === siteName &&
 											selectedSite?.phpVersion === selectedPhpVersion &&
-											currentWpVersion === selectedWpVersion ) ||
-										! siteName.trim()
+											currentWpVersion === selectedWpVersion &&
path: src/modules/site-settings/edit-site-details.tsx
user: login: bcotrim
body: The disabled state logic is a bit long and could be made more readable by extracting it into meaningful variables:

```typescript
const isFormUnchanged = selectedSite && 
    selectedSite.name === siteName &&
    selectedSite.phpVersion === selectedPhpVersion &&
    currentWpVersion === selectedWpVersion &&
    Boolean(selectedSite.customDomain) === useCustomDomain &&
    (!useCustomDomain || selectedSite.customDomain === customDomain);

const hasValidationErrors = !selectedSite || 
    !siteName.trim() || 
    (useCustomDomain && customDomainError);

disabled={isEditingSite || isFormUnchanged || hasValidationErrors}
```

This would make the code more self-documenting and easier to maintain. What do you think?
created_at: 2025-03-20T10:30:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -214,8 +272,11 @@ export default function EditSiteDetails( { currentWpVersion, onSave }: EditSiteD
 										! selectedSite ||
 										( selectedSite?.name === siteName &&
 											selectedSite?.phpVersion === selectedPhpVersion &&
-											currentWpVersion === selectedWpVersion ) ||
-										! siteName.trim()
+											currentWpVersion === selectedWpVersion &&
path: src/modules/site-settings/edit-site-details.tsx
user: login: youknowriad
body: Sounds good to me :) 
created_at: 2025-03-20T12:07:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: youknowriad
created_at: 2025-03-19T09:37:57Z
body: Can I get some reviews here?
author_association: CONTRIBUTOR
user: login: youknowriad
created_at: 2025-03-20T06:40:02Z
body: I fixed the validation bug.
author_association: CONTRIBUTOR
user: login: wojtekn
created_at: 2025-03-21T08:53:41Z
body: > Having an invalid domain disables the submit, even when "Use custom domain" isn't checked. I recorded on "Edit Site" but the same behavior happens on "Add Site":

@bcotrim I tested it when I rebased https://github.com/Automattic/studio/pull/1078 and noticed it is fixed in Add Site, but it still happens in Edit Site. Are you also reproduce it there?
author_association: CONTRIBUTOR
user: login: bcotrim
created_at: 2025-03-21T10:03:24Z
body: > > Having an invalid domain disables the submit, even when "Use custom domain" isn't checked. I recorded on "Edit Site" but the same behavior happens on "Add Site":
> 
> @bcotrim I tested it when I rebased #1078 and noticed it is fixed in Add Site, but it still happens in Edit Site. Are you also reproduce it there?

I don't see it happening on "Add Site" or "Edit Site".
Note that if you don't have any change compared the existing settings the button is still disabled due to the form being unchanged, not the validation error.


https://github.com/user-attachments/assets/39ed88de-b887-49f6-93da-46e01d47e3c9


author_association: CONTRIBUTOR
user: login: wojtekn
created_at: 2025-03-21T12:28:05Z
body: > Note that if you don't have any change compared the existing settings the button is still disabled due to the form being unchanged, not the validation error.

Oh, it was that. Thanks for clarifying. When I edit the site name, then enable a custom domain and enter an invalid one, Save button toggles correctly when I keep disabling and enabling a custom domain.
author_association: CONTRIBUTOR