Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions packages/playground/website/playwright/e2e/website-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('./');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,6 +59,7 @@ export function Sidebar({
const onSiteClick = (slug: string) => {
dispatch(setActiveSite(slug));
dispatch(setSiteManagerSection('site-details'));
dispatch(setActiveTab('settings'));
afterSiteClick?.(slug);
};

Expand Down Expand Up @@ -190,7 +194,7 @@ export function Sidebar({
{...(activeSite?.metadata.storage === 'none'
? {
'aria-current': 'page',
}
}
: {})}
>
<HStack justify="flex-start" alignment="center">
Expand Down Expand Up @@ -269,7 +273,7 @@ export function Sidebar({
{...(isSelected
? {
'aria-current': 'page',
}
}
: {})}
>
<HStack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
setSiteManagerOpen,
setSiteManagerSection,
setActiveModal,
setActiveTab,
modalSlugs,
} from '../../../lib/state/redux/slice-ui';
import {
Expand Down Expand Up @@ -91,12 +92,18 @@ export function SiteInfoPanel({
siteViewHidden?: boolean;
}) {
const offline = useAppSelector((state) => 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
Expand Down Expand Up @@ -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);
};

Expand Down
43 changes: 36 additions & 7 deletions packages/playground/website/src/lib/state/redux/slice-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<string, any>
): Record<string, any> {
const uiOnlyParams = ['route', 'modal'];
return Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => !uiOnlyParams.includes(key)
)
);
}

/**
* The supported site storage types.
*
Expand Down
Loading
Loading