From b2a4ad991db7aaa0cee9410b345fb76792fbb405 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 21:31:23 -0400 Subject: [PATCH 1/2] Studio: Apply site events to the site list instead of refetching it --- apps/cli/commands/_events.ts | 11 +-- apps/cli/commands/site/list.ts | 14 +--- apps/cli/commands/site/tests/list.test.ts | 4 +- apps/cli/lib/site-details.ts | 18 +++++ apps/studio/src/ipc-handlers.ts | 10 +-- .../modules/cli/lib/cli-events-subscriber.ts | 11 ++- .../lib/tests/cli-events-subscriber.test.ts | 11 +-- apps/ui/src/data/queries/use-sites.test.tsx | 48 +++++++++++++ apps/ui/src/data/queries/use-sites.ts | 68 ++++++++++++++++--- 9 files changed, 148 insertions(+), 47 deletions(-) create mode 100644 apps/cli/lib/site-details.ts diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index 79c27fbdcc..efa3f6b69b 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -23,7 +23,7 @@ import { sequential } from '@studio/common/lib/sequential'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { readCliConfig, SiteData } from 'cli/lib/cli-config/core'; -import { getSiteUrl, removeSiteFromConfig } from 'cli/lib/cli-config/sites'; +import { removeSiteFromConfig } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -32,7 +32,7 @@ import { getDaemonBus, mcpEventsSocketPath, } from 'cli/lib/daemon-client'; -import { getLiveSiteOperation } from 'cli/lib/site-operations'; +import { getSiteDetails } from 'cli/lib/site-details'; import { isSiteRunning } from 'cli/lib/site-utils'; import { SocketServer } from 'cli/lib/socket'; import { SITE_PROCESS_PREFIX } from 'cli/lib/wordpress-server-manager'; @@ -41,12 +41,7 @@ import { Logger, LoggerError } from 'cli/logger'; const logger = new Logger< LoggerAction >(); function toSiteDetails( site: SiteData ) { - return siteDetailsSchema.parse( { - ...site, - // Overrides rather than augments `...site` — see the note in `site list`. - operation: getLiveSiteOperation( site ), - url: getSiteUrl( site ), - } ); + return siteDetailsSchema.parse( getSiteDetails( site ) ); } const emitSiteEvent = sequential( diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index 2ce64bf60d..b4f49df179 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -5,8 +5,7 @@ import CliTable3 from 'cli-table3'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; -import { getSiteEmailDirectory } from 'cli/lib/site-email'; -import { getLiveSiteOperation } from 'cli/lib/site-operations'; +import { getSiteDetails } from 'cli/lib/site-details'; import { pickPublicSiteFields } from 'cli/lib/site-public-fields'; import { isSiteRunning } from 'cli/lib/site-utils'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; @@ -46,16 +45,7 @@ async function getSiteListData( sites: SiteData[] ): Promise< { url, } ); - jsonEntries.push( { - ...site, - // Overrides the stored value from `...site`: it can still name a - // process that has died, and both front ends decide which site - // actions to disable from what this reports. - operation: getLiveSiteOperation( site ), - url, - running, - emailPath: getSiteEmailDirectory( site.id ), - } ); + jsonEntries.push( { ...getSiteDetails( site ), running } ); } return { tableEntries, jsonEntries }; diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index 1ac34c3ecc..c67e0f1ba8 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -87,8 +87,8 @@ describe( 'CLI: studio site list', () => { port: 8080, phpVersion: '8.0', url: 'http://localhost:8080', - running: false, emailPath: getSiteEmailDirectory( 'site-1' ), + running: false, }, { id: 'site-2', @@ -98,8 +98,8 @@ describe( 'CLI: studio site list', () => { phpVersion: '8.0', customDomain: 'my-site.wp.local', url: 'http://my-site.wp.local', - running: false, emailPath: getSiteEmailDirectory( 'site-2' ), + running: false, }, ]; expect( consoleSpy ).toHaveBeenCalledWith( JSON.stringify( publicSites ) ); diff --git a/apps/cli/lib/site-details.ts b/apps/cli/lib/site-details.ts new file mode 100644 index 0000000000..3dc85905d4 --- /dev/null +++ b/apps/cli/lib/site-details.ts @@ -0,0 +1,18 @@ +import { getSiteUrl } from 'cli/lib/cli-config/sites'; +import { getSiteEmailDirectory } from 'cli/lib/site-email'; +import { getLiveSiteOperation } from 'cli/lib/site-operations'; +import type { SiteData } from 'cli/lib/cli-config/core'; + +// A site as Studio shows it, built once for both `site list` and the events +// Studio applies to its list, so the two can't drift apart. +export function getSiteDetails( site: SiteData ) { + return { + ...site, + // Overrides the stored value from `...site`: it can still name a process + // that has died, and both front ends decide which site actions to disable + // from what this reports. + operation: getLiveSiteOperation( site ), + url: getSiteUrl( site ), + emailPath: getSiteEmailDirectory( site.id ), + }; +} diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 15622c9469..7f419fd7d2 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -653,17 +653,11 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom await startErrorRecovery( details, errorMessage, readProcessManagerLogs, start ); console.log( `[PHP Recovery - ${ id }] Serving PHP error page on port ${ details.port }` ); const url = `http://localhost:${ details.port }`; + // Without the site's details, so the renderer refetches the list, where + // `getSites` gives the error page's URL. void sendIpcEventToRenderer( 'site-event', { event: SITE_EVENTS.UPDATED, siteId: id, - site: { - id, - name: details.name, - path: details.path, - port: details.port, - url, - phpVersion: details.phpVersion, - }, running: true, } ); // Refresh the thumbnail so it shows the error page instead of a stale capture. diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index bcc7125b55..e997c87557 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -41,9 +41,14 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > return; } - // A site serving a PHP-error page stays "running" even though the CLI reports it stopped. - const running = event.running || isInErrorRecovery( siteId ); - void sendIpcEventToRenderer( 'site-event', { ...event, running } ); + // A site serving a PHP-error page stays "running" even though the CLI reports it stopped. Its + // details go without, so the renderer refetches the list, where `getSites` gives the page's URL. + const inErrorRecovery = isInErrorRecovery( siteId ); + const running = event.running || inErrorRecovery; + void sendIpcEventToRenderer( + 'site-event', + inErrorRecovery ? { event: eventType, siteId, running } : { ...event, running } + ); await seeded; const wasRunning = lastRunning.get( siteId ) ?? false; lastRunning.set( siteId, running ); diff --git a/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts index 85681a3392..8717e59610 100644 --- a/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts +++ b/apps/studio/src/modules/cli/lib/tests/cli-events-subscriber.test.ts @@ -72,16 +72,17 @@ describe( 'CLI events subscriber', () => { expect( captureSiteThumbnail ).toHaveBeenCalledTimes( 1 ); } ); - it( 'reports a site serving the PHP error page as running', async () => { + it( 'reports a site serving the PHP error page as running, without its CLI details', async () => { await emitSiteEvent( SITE_EVENTS.CREATED, 'site-b', true ); vi.mocked( isInErrorRecovery ).mockReturnValue( true ); await emitSiteEvent( SITE_EVENTS.UPDATED, 'site-b', false ); - expect( sendIpcEventToRenderer ).toHaveBeenLastCalledWith( - 'site-event', - expect.objectContaining( { siteId: 'site-b', running: true } ) - ); + expect( sendIpcEventToRenderer ).toHaveBeenLastCalledWith( 'site-event', { + event: SITE_EVENTS.UPDATED, + siteId: 'site-b', + running: true, + } ); } ); it( 'forgets a deleted site', async () => { diff --git a/apps/ui/src/data/queries/use-sites.test.tsx b/apps/ui/src/data/queries/use-sites.test.tsx index d4812dc9d0..be3393422a 100644 --- a/apps/ui/src/data/queries/use-sites.test.tsx +++ b/apps/ui/src/data/queries/use-sites.test.tsx @@ -1,3 +1,4 @@ +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { QueryClientProvider } from '@tanstack/react-query'; import { act, render, renderHook, screen, waitFor } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; @@ -154,6 +155,53 @@ describe( 'useStartSite', () => { } ); describe( 'useSyncSitesWithEvents', () => { + it.each( [ + [ 'start', true ], + [ 'stop', false ], + ] as const )( + 'shows a %s from its events without refetching the list', + async ( action, running ) => { + // What the CLI reports, and the sort order only Studio keeps. + const details = { ...makeSite(), url: 'http://localhost:9010' }; + const site = { ...details, running: ! running, sortOrder: 1000 }; + const connector = createFakeConnector( { sites: [ site ] } ); + connector[ action === 'start' ? 'startSite' : 'stopSite' ].mockImplementation( async () => { + const siteId = site.id; + const held = { ...details, operation: { pid: 1, kind: action } }; + connector.emit( 'onSiteEvent', { + event: SITE_EVENTS.UPDATED, + siteId, + site: held, + running, + } ); + // An operation's release doesn't know whether the site runs. + connector.emit( 'onSiteEvent', { + event: SITE_EVENTS.OPERATIONS_CHANGED, + siteId, + site: details, + running: ! running, + } ); + } ); + const { result } = renderHook( + () => { + useSyncSitesWithEvents(); + return { sites: useSites(), start: useStartSite(), stop: useStopSite() }; + }, + { wrapper: providers( connector ) } + ); + await waitFor( () => expect( result.current.sites.data ).toBeDefined() ); + + await act( async () => { + await result.current[ action ].mutateAsync( site.id ); + } ); + + await waitFor( () => + expect( result.current.sites.data ).toEqual( [ { ...site, running } ] ) + ); + expect( connector.getSites ).toHaveBeenCalledTimes( 1 ); + } + ); + it( 'refetches after an in-flight fetch that predates the event', async () => { const connector = createFakeConnector(); const pendingFetches: Array< ( sites: SiteDetails[] ) => void > = []; diff --git a/apps/ui/src/data/queries/use-sites.ts b/apps/ui/src/data/queries/use-sites.ts index 1197d82280..fa6b447835 100644 --- a/apps/ui/src/data/queries/use-sites.ts +++ b/apps/ui/src/data/queries/use-sites.ts @@ -11,7 +11,9 @@ import { SESSIONS_QUERY_KEY } from '@/data/queries/use-sessions'; import { WP_VERSION_QUERY_KEY } from '@/data/queries/use-wordpress-versions'; import { useOpenSite } from '@/data/use-open-site'; import type { Connector, CreateSiteParams, SiteDetails } from '@/data/core'; +import type { SiteEvent } from '@studio/common/lib/cli-events'; import type { SiteOperationKind } from '@studio/common/lib/site-operation'; +import type { SiteMetadata } from '@studio/common/sites/metadata'; import type { QueryClient } from '@tanstack/react-query'; export const SITES_QUERY_KEY = [ 'sites' ] as const; @@ -156,7 +158,9 @@ export function useStartSite() { return false; } await connector.startSite( id ); - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + if ( ! listShowsSite( queryClient, id, true ) ) { + await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + } return true; }, onError: ( error, id ) => { @@ -178,7 +182,9 @@ export function useStopSite() { mutationKey: STOP_SITE_MUTATION_KEY, mutationFn: async ( id: string ) => { await connector.stopSite( id ); - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + if ( ! listShowsSite( queryClient, id, false ) ) { + await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + } }, onError: ( _error, id ) => { const { title, description } = getBusyNotice( queryClient, id, __( 'Failed to stop site' ) ); @@ -356,6 +362,47 @@ export function useIsSiteBusy( site: SiteDetails | undefined ): boolean { return isStarting || isStopping || operation !== null; } +/** + * Writes the site an event carries into the cached list, so it needs no + * refetch. False when the list can't take it: no list yet, a site it doesn't + * have, or a create or delete, which change which sites there are. + */ +function applySiteEvent( queryClient: QueryClient, event: SiteEvent ): boolean { + const { site } = event; + const sites = queryClient.getQueryData< SiteDetails[] >( SITES_QUERY_KEY ); + const cached = sites?.find( ( item ) => item.id === site?.id ); + const applies = + event.event === SITE_EVENTS.UPDATED || event.event === SITE_EVENTS.OPERATIONS_CHANGED; + if ( ! site || ! sites || ! cached || ! applies ) { + return false; + } + // Studio's own metadata, which events don't carry. `satisfies` stops a new + // field from compiling until it's carried over too. + const metadata = { + sortOrder: cached.sortOrder, + autoStart: cached.autoStart, + } satisfies Record< keyof SiteMetadata, unknown >; + const updated: SiteDetails = { + ...site, + // An operation change says nothing about whether the site is running. + running: event.event === SITE_EVENTS.UPDATED ? event.running : cached.running, + ...metadata, + }; + queryClient.setQueryData< SiteDetails[] >( + SITES_QUERY_KEY, + sites.map( ( item ) => ( item === cached ? updated : item ) ) + ); + return true; +} + +/** Whether the cached list already shows the site in this state, with no operation holding it. */ +function listShowsSite( queryClient: QueryClient, id: string, running: boolean ): boolean { + const site = queryClient + .getQueryData< SiteDetails[] >( SITES_QUERY_KEY ) + ?.find( ( item ) => item.id === id ); + return site?.running === running && ! site.operation; +} + /** * Keeps the cached site list in sync with main-process events (site created, * updated, started, stopped, deleted). Mount once near the app root. @@ -365,17 +412,20 @@ export function useSyncSitesWithEvents(): void { const queryClient = useQueryClient(); useEffect( () => { return connector.onSiteEvent( ( event ) => { + const applied = applySiteEvent( queryClient, event ); // Keeping the in-flight fetch means joining it, and it may have read the // sites before this event (an operation's release lands moments after its // acquire). Refetch once more after it settles so the event isn't lost. const joinsInFlightFetch = queryClient.isFetching( { queryKey: SITES_QUERY_KEY } ) > 0; - void queryClient - .invalidateQueries( { queryKey: SITES_QUERY_KEY }, KEEP_INFLIGHT_FETCH ) - .then( () => - joinsInFlightFetch - ? queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY }, KEEP_INFLIGHT_FETCH ) - : undefined - ); + if ( ! applied || joinsInFlightFetch ) { + void queryClient + .invalidateQueries( { queryKey: SITES_QUERY_KEY }, KEEP_INFLIGHT_FETCH ) + .then( () => + joinsInFlightFetch + ? queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY }, KEEP_INFLIGHT_FETCH ) + : undefined + ); + } // Site deletion deletes the site's chat sessions (CLI `site delete`), // so refresh the session list too. Scoped to deletes: start/stop // events fire often and don't affect sessions. `exact` keeps this off From bac99cfa58572928da7e6faf2598f25f36d5e7fc Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 27 Oct 2026 22:11:11 -0400 Subject: [PATCH 2/2] Studio: Auto-start from the list read at launch, and report site changes made during the status sweep --- apps/cli/commands/_events.ts | 7 +++- apps/ui/src/data/queries/use-sites.test.tsx | 41 +++++++++++++++++++++ apps/ui/src/data/queries/use-sites.ts | 26 ++++++++----- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index efa3f6b69b..bef1ada4ac 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -195,8 +195,9 @@ export async function runCommand( listener: EventsListener = 'desktop' ): Promis await connectToDaemon(); logger.reportSuccess( __( 'Connected to process daemon' ) ); - await emitAllSitesStatus(); - + // Subscribed before the status sweep, so a site that starts or stops after + // the sweep reads it still gets an event. Events are sequential, so the + // later one lands last. const bus = await getDaemonBus(); // Subscribe to IPC events from site processes. We treat a `result` message with no return value @@ -229,6 +230,8 @@ export async function runCommand( listener: EventsListener = 'desktop' ): Promis bus.on( 'daemon-kill', () => { void emitAllSitesStopped(); } ); + + await emitAllSitesStatus(); } export async function commandHandler( listener?: EventsListener ) { diff --git a/apps/ui/src/data/queries/use-sites.test.tsx b/apps/ui/src/data/queries/use-sites.test.tsx index be3393422a..d2309846a9 100644 --- a/apps/ui/src/data/queries/use-sites.test.tsx +++ b/apps/ui/src/data/queries/use-sites.test.tsx @@ -47,6 +47,47 @@ describe( 'useAutoStartSites', () => { expect( connector.startSite ).toHaveBeenCalledTimes( 1 ); expect( getVisibleToasts() ).toHaveLength( 0 ); } ); + + it( 'starts from the list read this launch, not the one persisted before the quit', async () => { + const site = { ...makeSite(), url: 'http://localhost:9010' }; + const connector = createFakeConnector(); + let readList = ( _sites: SiteDetails[] ) => {}; + connector.getSites.mockImplementation( + () => new Promise< SiteDetails[] >( ( resolve ) => ( readList = resolve ) ) + ); + const queryClient = createTestQueryClient(); + queryClient.setQueryData( [ 'sites' ], [ site ] ); + const { result } = renderHook( + () => { + useSyncSitesWithEvents(); + useAutoStartSites(); + return useSites(); + }, + { + wrapper: ( { children } ) => ( + + { children } + + ), + } + ); + + // The status the CLI reports for each site as the app launches, landing + // before the list is read. + const reported = { ...site, name: 'Renamed' }; + act( () => + connector.emit( 'onSiteEvent', { + event: SITE_EVENTS.UPDATED, + siteId: site.id, + site: reported, + running: false, + } ) + ); + await waitFor( () => expect( result.current.data?.[ 0 ].name ).toBe( 'Renamed' ) ); + await act( async () => readList( [ { ...reported, autoStart: true } ] ) ); + + await waitFor( () => expect( connector.startSite ).toHaveBeenCalledWith( site.id ) ); + } ); } ); describe( 'useCreateSite', () => { diff --git a/apps/ui/src/data/queries/use-sites.ts b/apps/ui/src/data/queries/use-sites.ts index fa6b447835..3a08eede7b 100644 --- a/apps/ui/src/data/queries/use-sites.ts +++ b/apps/ui/src/data/queries/use-sites.ts @@ -442,21 +442,27 @@ export function useSyncSitesWithEvents(): void { // Boot-time counterpart of the "Stop, restart on next launch" quit behavior: // those sites keep their autoStart flag when stopped on quit, and the renderer -// starts them again on launch. Gated on isFetchedAfterMount so a rehydrated -// persisted cache with stale flags can't trigger starts. +// starts them again on launch. Reads the list this launch, joining the fetch +// already in flight, because the persisted list predates the flags the quit set. export function useAutoStartSites(): void { - const { data: sites, isFetchedAfterMount } = useSites(); + const connector = useConnector(); + const queryClient = useQueryClient(); const { mutate: startSite } = useStartSite(); const startedRef = useRef( false ); useEffect( () => { - if ( ! isFetchedAfterMount || ! sites || startedRef.current ) { + if ( startedRef.current ) { return; } startedRef.current = true; - for ( const site of sites ) { - if ( site.autoStart && ! site.running ) { - startSite( site.id ); - } - } - }, [ isFetchedAfterMount, sites, startSite ] ); + void queryClient + .fetchQuery( { queryKey: SITES_QUERY_KEY, queryFn: () => connector.getSites() } ) + .then( ( sites ) => { + for ( const site of sites ) { + if ( site.autoStart && ! site.running ) { + startSite( site.id ); + } + } + } ) + .catch( () => undefined ); + }, [ connector, queryClient, startSite ] ); }