From c4a76c94390cebf1a1a2b9683b4155870ac1944c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 20:07:44 +0100 Subject: [PATCH 01/10] [Website] Preserve UI navigation state in URL with ?route parameter Adds URL-based persistence for sidebar and tab state so users can bookmark or refresh without losing their place in the UI. The ?route parameter encodes: - Sidebar open/closed state - Active section (site-details, blueprints, sidebar list) - Active tab in site details (settings, files, blueprint, database, logs) Examples: ?route=details.files, ?route=blueprints, ?route=closed Key changes: - Add route parsing/building utilities in router.ts - Add activeTab to Redux UI state with URL sync - Filter UI-only params (route, modal) when comparing URLs to prevent unnecessary playground reloads when only navigation state changes - Reset to Settings tab when clicking a site in the sidebar - Add E2E tests for route persistence across page reloads --- .../website/playwright/e2e/website-ui.spec.ts | 153 ++++++++++++++++++ .../ensure-playground-site-is-selected.tsx | 11 +- .../components/site-manager/sidebar/index.tsx | 10 +- .../site-manager/site-info-panel/index.tsx | 15 +- .../src/lib/state/redux/slice-sites.ts | 22 ++- .../website/src/lib/state/redux/slice-ui.ts | 55 +++++-- .../website/src/lib/state/url/router.ts | 102 +++++++++++- 7 files changed, 339 insertions(+), 29 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index 71e5356935..c783d1ec33 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -714,6 +714,159 @@ 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 sidebar is open + await expect(website.page.locator('.main-sidebar')).toBeVisible(); + + // Verify we're on the blueprints section (look for blueprints gallery content) + 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..781e9124df 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -251,9 +251,11 @@ export function setTemporarySiteSpec( getState: () => PlaygroundReduxState ) => { const siteSlug = deriveSlugFromSiteName(siteName); + // Filter out UI-only params so they don't cause unnecessary site recreation. + // This ensures existing temporary sites are reused when only route/modal changed. const newSiteUrlParams = { - searchParams: parseSearchParams( - playgroundUrlWithQueryApiArgs.searchParams + searchParams: filterUIOnlyParams( + parseSearchParams(playgroundUrlWithQueryApiArgs.searchParams) ), hash: playgroundUrlWithQueryApiArgs.hash, }; @@ -410,6 +412,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..dac7c7813b 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,19 @@ 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')); + +// Determine if the sidebar should be forced closed +const shouldForceSidebarClosed = + // The site manager should not be shown at all in seamless mode. + query.get('mode') === 'seamless' || + // 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; const initialState: UIState = { /** @@ -144,22 +158,12 @@ 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 - // 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' && - // 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', + siteManagerIsOpen: shouldForceSidebarClosed + ? false + : routeState.sidebarOpen, + siteManagerSection: routeState.section, }; const uiSlice = createSlice({ @@ -223,12 +227,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 +290,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..02e04babaf 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,98 @@ 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 undefined if the sidebar is closed (default state). + */ +export function buildRouteParam(state: RouteState): string | undefined { + if (!state.sidebarOpen) { + return undefined; + } + + 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); + + if (routeValue === undefined) { + url.searchParams.delete('route'); + } else { + url.searchParams.set('route', routeValue); + } + + window.history.replaceState({}, '', url.href); +} + interface QueryAPIParams { name?: string; wp?: string; @@ -42,7 +135,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)) { From 48a688e5e4d96409b9adb08646ce2fd44549c441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 20:22:30 +0100 Subject: [PATCH 02/10] Restore the sidebar state documentation --- .../website/src/lib/state/redux/slice-ui.ts | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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 dac7c7813b..ef3e20ed3d 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -133,17 +133,7 @@ const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); -// Determine if the sidebar should be forced closed -const shouldForceSidebarClosed = - // The site manager should not be shown at all in seamless mode. - query.get('mode') === 'seamless' || - // 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; - +const shouldOpenSiteManagerByDefault = false; const initialState: UIState = { /** * Don't show certain modals after a page refresh. @@ -160,9 +150,22 @@ const initialState: UIState = { : query.get('modal') || null, activeTab: routeState.tab || 'settings', offline: !navigator.onLine, - siteManagerIsOpen: shouldForceSidebarClosed - ? false - : routeState.sidebarOpen, + + // NOTE: Please do not eliminate the cases in this siteManagerIsOpen expression, + // even if they seem redundant. We may experiment which 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' && + // 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) || + routeState.sidebarOpen, siteManagerSection: routeState.section, }; From c4d44f5ee12d0f8c26eb167d802d4a6c4dbf6212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 21:51:36 +0100 Subject: [PATCH 03/10] Fix: Respect route param regardless of viewport size --- .../website/src/lib/state/redux/slice-ui.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) 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 ef3e20ed3d..5d03da667f 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -133,7 +133,11 @@ const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); -const shouldOpenSiteManagerByDefault = false; +// Check if there's an explicit route param requesting the sidebar to be open. +// If so, respect it regardless of viewport size (but not in seamless mode). +const hasExplicitRouteParam = query.has('route') && routeState.sidebarOpen; +const isSeamlessMode = query.get('mode') === 'seamless'; + const initialState: UIState = { /** * Don't show certain modals after a page refresh. @@ -151,21 +155,11 @@ const initialState: UIState = { 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 - // to be open by default or closed by default, and we do not want to lose - // specific reasons for the manager to be closed. + // The site manager should not be shown at all in seamless mode or in iframes. + // Otherwise, if there's an explicit route param requesting it open, respect that. + // If no route param, default to closed (mobile or not). siteManagerIsOpen: - (shouldOpenSiteManagerByDefault && - // The site manager should not be shown at all in seamless mode. - query.get('mode') !== 'seamless' && - // 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) || - routeState.sidebarOpen, + !isSeamlessMode && !isEmbeddedInAnIframe && hasExplicitRouteParam, siteManagerSection: routeState.section, }; From 925852b26fc125baa6b096e16a64b055cc664248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 22:17:22 +0100 Subject: [PATCH 04/10] Remove unused isMobile variable --- packages/playground/website/src/lib/state/redux/slice-ui.ts | 2 -- 1 file changed, 2 deletions(-) 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 5d03da667f..6c31c360de 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -127,8 +127,6 @@ export interface UIState { const query = new URL(document.location.href).searchParams; const isEmbeddedInAnIframe = window.self !== window.top; -// @TODO: Centralize these breakpoint sizes. -const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); From 0ea80afd7d7fae930c9ff35762e502833eedd9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 22:20:29 +0100 Subject: [PATCH 05/10] Restore detailed comments for siteManagerIsOpen logic --- .../website/src/lib/state/redux/slice-ui.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 6c31c360de..16d42308f2 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -127,12 +127,15 @@ export interface UIState { const query = new URL(document.location.href).searchParams; const isEmbeddedInAnIframe = window.self !== window.top; +// @TODO: Centralize these breakpoint sizes. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); // Check if there's an explicit route param requesting the sidebar to be open. -// If so, respect it regardless of viewport size (but not in seamless mode). +// If so, respect it regardless of viewport size (but not in seamless mode or iframes). const hasExplicitRouteParam = query.has('route') && routeState.sidebarOpen; const isSeamlessMode = query.get('mode') === 'seamless'; @@ -153,11 +156,18 @@ const initialState: UIState = { activeTab: routeState.tab || 'settings', offline: !navigator.onLine, - // The site manager should not be shown at all in seamless mode or in iframes. - // Otherwise, if there's an explicit route param requesting it open, respect that. - // If no route param, default to closed (mobile or not). + // NOTE: Please do not eliminate the cases in this siteManagerIsOpen expression, + // 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: - !isSeamlessMode && !isEmbeddedInAnIframe && hasExplicitRouteParam, + // The site manager should not be shown at all in seamless mode. + !isSeamlessMode && + // We do not expect to render the Playground app UI in an iframe. + !isEmbeddedInAnIframe && + // If there's an explicit route param requesting sidebar open, respect it. + // Otherwise default to closed on all viewport sizes. + hasExplicitRouteParam, siteManagerSection: routeState.section, }; From 42e5919102a4441d42d1218161da96fdcbc6a8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 28 Nov 2025 23:54:46 +0100 Subject: [PATCH 06/10] Fix blueprints route test to check content instead of sidebar --- .../playground/website/playwright/e2e/website-ui.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/playground/website/playwright/e2e/website-ui.spec.ts b/packages/playground/website/playwright/e2e/website-ui.spec.ts index c783d1ec33..721cc07935 100644 --- a/packages/playground/website/playwright/e2e/website-ui.spec.ts +++ b/packages/playground/website/playwright/e2e/website-ui.spec.ts @@ -773,10 +773,11 @@ test.describe('Route persistence', () => { // Navigate with route=blueprints await website.goto('./?route=blueprints'); - // Verify sidebar is open - await expect(website.page.locator('.main-sidebar')).toBeVisible(); + // Verify URL has route=blueprints + expect(website.page.url()).toContain('route=blueprints'); - // Verify we're on the blueprints section (look for blueprints gallery content) + // 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(); From ab812f7ac8ac2528c30e3da07337509696d3b40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 29 Nov 2025 00:28:06 +0100 Subject: [PATCH 07/10] Fix: Default sidebar to open on desktop, use route param as override --- .../website/src/lib/state/redux/slice-ui.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 16d42308f2..3581f3a380 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -128,17 +128,20 @@ export interface UIState { const query = new URL(document.location.href).searchParams; const isEmbeddedInAnIframe = window.self !== window.top; // @TODO: Centralize these breakpoint sizes. -// eslint-disable-next-line @typescript-eslint/no-unused-vars const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); - -// Check if there's an explicit route param requesting the sidebar to be open. -// If so, respect it regardless of viewport size (but not in seamless mode or iframes). -const hasExplicitRouteParam = query.has('route') && routeState.sidebarOpen; 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 = { /** * Don't show certain modals after a page refresh. @@ -165,9 +168,8 @@ const initialState: UIState = { !isSeamlessMode && // We do not expect to render the Playground app UI in an iframe. !isEmbeddedInAnIframe && - // If there's an explicit route param requesting sidebar open, respect it. - // Otherwise default to closed on all viewport sizes. - hasExplicitRouteParam, + // Use explicit route param if provided, otherwise default based on viewport. + shouldSidebarBeOpen, siteManagerSection: routeState.section, }; From ee4682057498b150ba30ced959ecaf648ccf8023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 29 Nov 2025 00:56:34 +0100 Subject: [PATCH 08/10] Revert "Fix: Default sidebar to open on desktop, use route param as override" This reverts commit ab812f7ac8ac2528c30e3da07337509696d3b40c. --- .../website/src/lib/state/redux/slice-ui.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 3581f3a380..16d42308f2 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -128,19 +128,16 @@ export interface UIState { const query = new URL(document.location.href).searchParams; const isEmbeddedInAnIframe = window.self !== window.top; // @TODO: Centralize these breakpoint sizes. +// eslint-disable-next-line @typescript-eslint/no-unused-vars const isMobile = window.innerWidth < 875; // 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; +// Check if there's an explicit route param requesting the sidebar to be open. +// If so, respect it regardless of viewport size (but not in seamless mode or iframes). +const hasExplicitRouteParam = query.has('route') && routeState.sidebarOpen; +const isSeamlessMode = query.get('mode') === 'seamless'; const initialState: UIState = { /** @@ -168,8 +165,9 @@ const initialState: UIState = { !isSeamlessMode && // We do not expect to render the Playground app UI in an iframe. !isEmbeddedInAnIframe && - // Use explicit route param if provided, otherwise default based on viewport. - shouldSidebarBeOpen, + // If there's an explicit route param requesting sidebar open, respect it. + // Otherwise default to closed on all viewport sizes. + hasExplicitRouteParam, siteManagerSection: routeState.section, }; From b502737c34105568f4213e926d66a0206ad003e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 29 Nov 2025 00:59:10 +0100 Subject: [PATCH 09/10] Fix: Preserve route param in URL, default sidebar open on desktop --- .../src/lib/state/redux/slice-sites.ts | 33 ++++++++++++------- .../website/src/lib/state/redux/slice-ui.ts | 18 +++++----- 2 files changed, 32 insertions(+), 19 deletions(-) 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 781e9124df..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,14 +251,19 @@ export function setTemporarySiteSpec( getState: () => PlaygroundReduxState ) => { const siteSlug = deriveSlugFromSiteName(siteName); - // Filter out UI-only params so they don't cause unnecessary site recreation. - // This ensures existing temporary sites are reused when only route/modal changed. - const newSiteUrlParams = { - searchParams: filterUIOnlyParams( - parseSearchParams(playgroundUrlWithQueryApiArgs.searchParams) + // 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; @@ -267,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(), @@ -318,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; } @@ -373,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(), 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 16d42308f2..3581f3a380 100644 --- a/packages/playground/website/src/lib/state/redux/slice-ui.ts +++ b/packages/playground/website/src/lib/state/redux/slice-ui.ts @@ -128,17 +128,20 @@ export interface UIState { const query = new URL(document.location.href).searchParams; const isEmbeddedInAnIframe = window.self !== window.top; // @TODO: Centralize these breakpoint sizes. -// eslint-disable-next-line @typescript-eslint/no-unused-vars const isMobile = window.innerWidth < 875; // Parse the route parameter for sidebar/tab state const routeState = parseRouteParam(query.get('route')); - -// Check if there's an explicit route param requesting the sidebar to be open. -// If so, respect it regardless of viewport size (but not in seamless mode or iframes). -const hasExplicitRouteParam = query.has('route') && routeState.sidebarOpen; 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 = { /** * Don't show certain modals after a page refresh. @@ -165,9 +168,8 @@ const initialState: UIState = { !isSeamlessMode && // We do not expect to render the Playground app UI in an iframe. !isEmbeddedInAnIframe && - // If there's an explicit route param requesting sidebar open, respect it. - // Otherwise default to closed on all viewport sizes. - hasExplicitRouteParam, + // Use explicit route param if provided, otherwise default based on viewport. + shouldSidebarBeOpen, siteManagerSection: routeState.section, }; From 0237b940b5f6c3a04183e7b95edeb51d0f44c7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 29 Nov 2025 01:44:31 +0100 Subject: [PATCH 10/10] Fix: Set route=closed when sidebar is closed instead of deleting param --- .../playground/website/src/lib/state/url/router.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index 02e04babaf..e0b5ae30c0 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -60,11 +60,11 @@ export function parseRouteParam(route: string | null): RouteState { /** * Build a route parameter string from a RouteState object. - * Returns undefined if the sidebar is closed (default state). + * Returns 'closed' if the sidebar is closed. */ -export function buildRouteParam(state: RouteState): string | undefined { +export function buildRouteParam(state: RouteState): string { if (!state.sidebarOpen) { - return undefined; + return 'closed'; } if (state.section === 'sidebar') { @@ -89,13 +89,7 @@ export function buildRouteParam(state: RouteState): string | undefined { export function updateRouteInUrl(state: RouteState): void { const url = new URL(window.location.href); const routeValue = buildRouteParam(state); - - if (routeValue === undefined) { - url.searchParams.delete('route'); - } else { - url.searchParams.set('route', routeValue); - } - + url.searchParams.set('route', routeValue); window.history.replaceState({}, '', url.href); }