diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 71e5356935..721cc07935 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -714,6 +714,160 @@ test('should edit a blueprint in the blueprint editor and recreate the playgroun }); }); +test.describe('Route persistence', () => { + test('should preserve sidebar open state with details section after reload', async ({ + website, + }) => { + // Navigate with route=details (sidebar open, site-details section) + await website.goto('./?route=details'); + + // Verify sidebar is open + const siteManager = website.page.locator('.main-sidebar'); + await expect(siteManager).toBeVisible(); + + // Verify we're on the site-details section (Settings tab should be visible) + await expect( + website.page.getByRole('tab', { name: 'Settings' }) + ).toBeVisible(); + + // Reload the page + await website.page.reload(); + await website.waitForNestedIframes(); + + // Verify sidebar is still open after reload + await expect(siteManager).toBeVisible(); + + // Verify URL still has route=details + expect(website.page.url()).toContain('route=details'); + }); + + test('should preserve specific tab state after reload', async ({ + website, + }) => { + // Navigate with route=details.files (sidebar open, files tab) + await website.goto('./?route=details.files'); + + // Verify sidebar is open + await expect(website.page.locator('.main-sidebar')).toBeVisible(); + + // Verify Files tab is selected + const filesTab = website.page.getByRole('tab', { + name: 'File browser', + }); + await expect(filesTab).toHaveAttribute('aria-selected', 'true'); + + // Reload the page + await website.page.reload(); + await website.waitForNestedIframes(); + + // Verify Files tab is still selected after reload + await expect(filesTab).toHaveAttribute('aria-selected', 'true'); + + // Verify URL still has route=details.files + expect(website.page.url()).toContain('route=details.files'); + }); + + test('should preserve blueprints section after reload', async ({ + website, + }) => { + // Navigate with route=blueprints + await website.goto('./?route=blueprints'); + + // Verify URL has route=blueprints + expect(website.page.url()).toContain('route=blueprints'); + + // Verify site manager is open by checking for the blueprints content + // (In mobile view, only the active panel is shown, not the sidebar list) + await expect( + website.page.getByRole('heading', { name: 'Blueprints Gallery' }) + ).toBeVisible(); + + // Reload the page + await website.page.reload(); + await website.waitForNestedIframes(); + + // Verify we're still on blueprints section + await expect( + website.page.getByRole('heading', { name: 'Blueprints Gallery' }) + ).toBeVisible(); + + // Verify URL still has route=blueprints + expect(website.page.url()).toContain('route=blueprints'); + }); + + test('should update URL when switching tabs', async ({ website }) => { + await website.goto('./'); + await website.ensureSiteManagerIsOpen(); + + // Click on Files tab + await website.page.getByRole('tab', { name: 'File browser' }).click(); + + // Verify URL updated to include route=details.files + await expect(website.page).toHaveURL(/route=details\.files/); + + // Click on Blueprint tab + await website.page.getByRole('tab', { name: 'Blueprint' }).click(); + + // Verify URL updated to include route=details.blueprint + await expect(website.page).toHaveURL(/route=details\.blueprint/); + + // Click on Database tab + await website.page.getByRole('tab', { name: 'Database' }).click(); + + // Verify URL updated to include route=details.database + await expect(website.page).toHaveURL(/route=details\.database/); + + // Click on Logs tab + await website.page.getByRole('tab', { name: 'Logs' }).click(); + + // Verify URL updated to include route=details.logs + await expect(website.page).toHaveURL(/route=details\.logs/); + + // Click back on Settings tab + await website.page.getByRole('tab', { name: 'Settings' }).click(); + + // Verify URL updated to route=details (default tab doesn't need suffix) + await expect(website.page).toHaveURL(/route=details(?!\.)/); + }); + + test('should update URL when opening/closing sidebar', async ({ + website, + }) => { + await website.goto('./'); + + // Initially sidebar should be closed, URL should not have route param + await website.ensureSiteManagerIsClosed(); + expect(website.page.url()).not.toContain('route='); + + // Open sidebar + await website.ensureSiteManagerIsOpen(); + + // URL should now have route=details + await expect(website.page).toHaveURL(/route=details/); + + // Close sidebar + await website.ensureSiteManagerIsClosed(); + + // URL should not have route param (or route=closed which gets removed) + expect(website.page.url()).not.toMatch(/route=details/); + }); + + test('should preserve closed sidebar state after reload', async ({ + website, + }) => { + // Navigate without route param (sidebar closed by default) + await website.goto('./'); + await website.ensureSiteManagerIsClosed(); + + // Reload the page + await website.page.reload(); + await website.waitForNestedIframes(); + + // Verify sidebar is still closed + await expect(website.page.locator('.main-sidebar')).not.toBeVisible(); + }); +}); + test.describe('Database panel', () => { test.beforeEach(async ({ website }) => { await website.goto('./'); diff --git a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx index eb2fc991bb..ce605f02c7 100644 --- a/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx +++ b/packages/playground/website/src/components/ensure-playground-site/ensure-playground-site-is-selected.tsx @@ -111,12 +111,15 @@ export function EnsurePlaygroundSiteIsSelected({ return; } - // If only the 'modal' parameter changes in searchParams, don't reload the page - const notRefreshingParam = 'modal'; + // If only UI-related parameters change in searchParams, don't reload the page. + // These params control UI state (modals, sidebar, tabs) not the playground itself. + const uiOnlyParams = ['modal', 'route']; const oldParams = new URLSearchParams(prevUrl?.search); const newParams = new URLSearchParams(url?.search); - oldParams.delete(notRefreshingParam); - newParams.delete(notRefreshingParam); + for (const param of uiOnlyParams) { + oldParams.delete(param); + newParams.delete(param); + } const avoidUnnecessaryTempSiteReload = activeSite && oldParams.toString() === newParams.toString(); if (avoidUnnecessaryTempSiteReload) { diff --git a/packages/playground/website/src/components/site-manager/sidebar/index.tsx b/packages/playground/website/src/components/site-manager/sidebar/index.tsx index 9ad0687d34..51dce8b94a 100644 --- a/packages/playground/website/src/components/site-manager/sidebar/index.tsx +++ b/packages/playground/website/src/components/site-manager/sidebar/index.tsx @@ -28,7 +28,10 @@ import { selectTemporarySite, } from '../../../lib/state/redux/slice-sites'; import { PlaygroundRoute, redirectTo } from '../../../lib/state/url/router'; -import { setSiteManagerSection } from '../../../lib/state/redux/slice-ui'; +import { + setSiteManagerSection, + setActiveTab, +} from '../../../lib/state/redux/slice-ui'; import { WordPressPRMenuItem } from '../../toolbar-buttons/wordpress-pr-menu-item'; import { GutenbergPRMenuItem } from '../../toolbar-buttons/gutenberg-pr-menu-item'; import { RestoreFromZipMenuItem } from '../../toolbar-buttons/restore-from-zip'; @@ -56,6 +59,7 @@ export function Sidebar({ const onSiteClick = (slug: string) => { dispatch(setActiveSite(slug)); dispatch(setSiteManagerSection('site-details')); + dispatch(setActiveTab('settings')); afterSiteClick?.(slug); }; @@ -190,7 +194,7 @@ export function Sidebar({ {...(activeSite?.metadata.storage === 'none' ? { 'aria-current': 'page', - } + } : {})} > @@ -269,7 +273,7 @@ export function Sidebar({ {...(isSelected ? { 'aria-current': 'page', - } + } : {})} > state.ui.offline); + const activeTab = useAppSelector((state) => state.ui.activeTab); const dispatch = useAppDispatch(); - // Load the last active tab for this site + // Use Redux activeTab if set, otherwise fall back to localStorage for backwards compatibility const [initialTabName] = useState(() => { + // If route param provided a tab, use it + if (activeTab && activeTab !== 'settings') { + return activeTab; + } + // Otherwise check localStorage for this site's last tab const lastTab = getSiteLastTab(site.slug); - return lastTab || 'settings'; + return lastTab || activeTab || 'settings'; }); // Resolve documentRoot from playground client @@ -127,8 +134,10 @@ export function SiteInfoPanel({ })(); }, [site.metadata.originalBlueprint]); - // Save the tab when it changes + // Save the tab when it changes - update Redux (which syncs to URL) + // and also localStorage for backwards compatibility const handleTabSelect = (tabName: string) => { + dispatch(setActiveTab(tabName)); setSiteLastTab(site.slug, tabName); }; diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index 9f6fc34d50..086d4fd5bb 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -251,12 +251,19 @@ export function setTemporarySiteSpec( getState: () => PlaygroundReduxState ) => { const siteSlug = deriveSlugFromSiteName(siteName); - const newSiteUrlParams = { + // Store the full URL params including UI params like route/modal. + const fullUrlParams = { searchParams: parseSearchParams( playgroundUrlWithQueryApiArgs.searchParams ), hash: playgroundUrlWithQueryApiArgs.hash, }; + // For comparison, filter out UI-only params so we don't recreate + // the site when only route/modal changed. + const paramsForComparison = { + searchParams: filterUIOnlyParams(fullUrlParams.searchParams), + hash: fullUrlParams.hash, + }; const showTemporarySiteError = (params: { error: SiteError; @@ -265,7 +272,7 @@ export function setTemporarySiteSpec( // Create a mock temporary site to associate the error with. const errorSite: SiteInfo = { slug: siteSlug, - originalUrlParams: newSiteUrlParams, + originalUrlParams: fullUrlParams, metadata: { name: siteName, id: crypto.randomUUID(), @@ -316,11 +323,17 @@ export function setTemporarySiteSpec( const currentTemporarySite = selectTemporarySite(getState()); if (currentTemporarySite) { - // If the current temporary site is the same as the site we're setting, - // then we don't need to create a new site. + // If the current temporary site has the same non-UI params, + // we don't need to create a new site. + const currentParamsForComparison = { + searchParams: filterUIOnlyParams( + currentTemporarySite.originalUrlParams?.searchParams || {} + ), + hash: currentTemporarySite.originalUrlParams?.hash || '', + }; if ( - JSON.stringify(currentTemporarySite.originalUrlParams) === - JSON.stringify(newSiteUrlParams) + JSON.stringify(currentParamsForComparison) === + JSON.stringify(paramsForComparison) ) { return currentTemporarySite; } @@ -371,7 +384,7 @@ export function setTemporarySiteSpec( // Compute the runtime configuration based on the resolved Blueprint: const newSiteInfo: SiteInfo = { slug: siteSlug, - originalUrlParams: newSiteUrlParams, + originalUrlParams: fullUrlParams, metadata: { name: siteName, id: crypto.randomUUID(), @@ -410,6 +423,22 @@ function parseSearchParams(searchParams: URLSearchParams) { return params; } +/** + * Filter out UI-only params that don't affect the playground site itself. + * These params control UI state (modals, sidebar, tabs) and should not + * cause a temporary site to be recreated when they change. + */ +function filterUIOnlyParams( + searchParams: Record +): Record { + const uiOnlyParams = ['route', 'modal']; + return Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => !uiOnlyParams.includes(key) + ) + ); +} + /** * The supported site storage types. * diff --git a/packages/playground/website/src/lib/state/redux/slice-ui.ts b/packages/playground/website/src/lib/state/redux/slice-ui.ts index 14d22013cd..3581f3a380 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -1,6 +1,7 @@ import type { PayloadAction, Middleware } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import { BlueprintStepExecutionError } from '@wp-playground/blueprints'; +import { parseRouteParam, updateRouteInUrl } from '../url/router'; export type SiteError = | 'directory-handle-not-found-in-indexeddb' @@ -117,6 +118,7 @@ export interface UIState { errorDetails?: SerializedSiteErrorDetails; }; activeModal: string | null; + activeTab: string; githubAuthRepoUrl?: string; offline: boolean; siteManagerIsOpen: boolean; @@ -128,7 +130,17 @@ const isEmbeddedInAnIframe = window.self !== window.top; // @TODO: Centralize these breakpoint sizes. const isMobile = window.innerWidth < 875; -const shouldOpenSiteManagerByDefault = false; +// Parse the route parameter for sidebar/tab state +const routeState = parseRouteParam(query.get('route')); +const isSeamlessMode = query.get('mode') === 'seamless'; + +// Determine initial sidebar open state: +// - If route param exists, use it (route=closed → closed, otherwise → open) +// - If no route param, default to open on desktop, closed on mobile +const hasExplicitRouteParam = query.has('route'); +const shouldSidebarBeOpen = hasExplicitRouteParam + ? routeState.sidebarOpen + : !isMobile; const initialState: UIState = { /** @@ -144,22 +156,21 @@ const initialState: UIState = { query.get('modal') === 'github-private-repo-auth' ? null : query.get('modal') || null, + activeTab: routeState.tab || 'settings', offline: !navigator.onLine, + // NOTE: Please do not eliminate the cases in this siteManagerIsOpen expression, - // even if they seem redundant. We may experiment which toggling the manager + // even if they seem redundant. We may experiment with toggling the manager // to be open by default or closed by default, and we do not want to lose // specific reasons for the manager to be closed. siteManagerIsOpen: - shouldOpenSiteManagerByDefault && // The site manager should not be shown at all in seamless mode. - query.get('mode') !== 'seamless' && + !isSeamlessMode && // We do not expect to render the Playground app UI in an iframe. !isEmbeddedInAnIframe && - // Don't default to the site manager on mobile, as that would mean - // seeing something that's not Playground filling your entire screen – - // quite a confusing experience. - !isMobile, - siteManagerSection: 'site-details', + // Use explicit route param if provided, otherwise default based on viewport. + shouldSidebarBeOpen, + siteManagerSection: routeState.section, }; const uiSlice = createSlice({ @@ -223,12 +234,30 @@ const uiSlice = createSlice({ }, setSiteManagerOpen: (state, action: PayloadAction) => { state.siteManagerIsOpen = action.payload; + updateRouteInUrl({ + sidebarOpen: action.payload, + section: state.siteManagerSection, + tab: state.activeTab, + }); }, setSiteManagerSection: ( state, action: PayloadAction ) => { state.siteManagerSection = action.payload; + updateRouteInUrl({ + sidebarOpen: state.siteManagerIsOpen, + section: action.payload, + tab: state.activeTab, + }); + }, + setActiveTab: (state, action: PayloadAction) => { + state.activeTab = action.payload; + updateRouteInUrl({ + sidebarOpen: state.siteManagerIsOpen, + section: state.siteManagerSection, + tab: action.payload, + }); }, }, }); @@ -268,6 +297,7 @@ export const listenToOnlineOfflineEventsMiddleware: Middleware = export const { setActiveModal, + setActiveTab, setActiveSiteError, clearActiveSiteError, setGitHubAuthRepoUrl, diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index efed33e00c..e0b5ae30c0 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -1,4 +1,5 @@ import type { SiteInfo } from '../redux/slice-sites'; +import type { SiteManagerSection } from '../redux/slice-ui'; import { updateUrl } from './router-hooks'; import { decodeBase64ToString } from '../../base64'; @@ -6,6 +7,92 @@ export function redirectTo(url: string) { window.history.pushState({}, '', url); } +/** + * Route state representation for UI navigation. + */ +export type RouteState = { + sidebarOpen: boolean; + section: SiteManagerSection; + tab?: string; +}; + +const VALID_TABS = ['settings', 'files', 'blueprint', 'database', 'logs']; + +/** + * Parse the `route` query parameter into a RouteState object. + * + * Route format: + * - "closed" or absent → sidebar closed + * - "sidebar" → sidebar open, section='sidebar' + * - "details" → sidebar open, section='site-details', tab='settings' + * - "details.{tab}" → sidebar open, section='site-details', specific tab + * - "blueprints" → sidebar open, section='blueprints' + */ +export function parseRouteParam(route: string | null): RouteState { + if (!route || route === 'closed') { + return { sidebarOpen: false, section: 'site-details' }; + } + + if (route === 'sidebar') { + return { sidebarOpen: true, section: 'sidebar' }; + } + + if (route === 'blueprints') { + return { sidebarOpen: true, section: 'blueprints' }; + } + + if (route === 'details') { + return { sidebarOpen: true, section: 'site-details', tab: 'settings' }; + } + + if (route.startsWith('details.')) { + const tab = route.substring('details.'.length); + if (VALID_TABS.includes(tab)) { + return { sidebarOpen: true, section: 'site-details', tab }; + } + // Invalid tab, fall back to settings + return { sidebarOpen: true, section: 'site-details', tab: 'settings' }; + } + + // Unknown route, default to closed + return { sidebarOpen: false, section: 'site-details' }; +} + +/** + * Build a route parameter string from a RouteState object. + * Returns 'closed' if the sidebar is closed. + */ +export function buildRouteParam(state: RouteState): string { + if (!state.sidebarOpen) { + return 'closed'; + } + + if (state.section === 'sidebar') { + return 'sidebar'; + } + + if (state.section === 'blueprints') { + return 'blueprints'; + } + + // section === 'site-details' + if (state.tab && state.tab !== 'settings') { + return `details.${state.tab}`; + } + + return 'details'; +} + +/** + * Update the route query parameter in the current URL using replaceState. + */ +export function updateRouteInUrl(state: RouteState): void { + const url = new URL(window.location.href); + const routeValue = buildRouteParam(state); + url.searchParams.set('route', routeValue); + window.history.replaceState({}, '', url.href); +} + interface QueryAPIParams { name?: string; wp?: string; @@ -42,7 +129,14 @@ export class PlaygroundRoute { return updateUrl(baseUrl, site.originalUrlParams || {}); } else { const baseParams = new URLSearchParams(baseUrl.split('?')[1]); - const preserveParamsKeys = ['mode', 'networking', 'login', 'url']; + // Preserve UI-related params and display mode params when switching sites + const preserveParamsKeys = [ + 'mode', + 'networking', + 'login', + 'url', + 'route', + ]; const preserveParams: Record = {}; for (const param of preserveParamsKeys) { if (baseParams.has(param)) {