Keep Issues and artist detail history stable

- Route Issues to the React host even while the shell is still booting
- Ignore stale bootstrap work when navigation changes mid-load
- Clear artist-detail state when leaving the page so browser back can reach Library
- Add smoke coverage for the artist-detail back-navigation path
This commit is contained in:
Antti Kettunen 2026-05-02 17:25:13 +03:00
parent 8d6ab4eb74
commit 50c2d6882c
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
6 changed files with 92 additions and 4 deletions

View file

@ -1,5 +1,7 @@
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
const PAGE_WILL_CHANGE_EVENT = 'ss:webui-page-will-change';
// Global state management
let currentPage = 'dashboard';
let currentTrack = null;

View file

@ -1,5 +1,20 @@
// INITIALIZATION
// ===============================
let navigationEpoch = 0;
function notifyPageWillChange(nextPageId) {
const fromPageId = typeof currentPage === 'string' ? currentPage : null;
if (fromPageId === nextPageId) return;
window.dispatchEvent(
new CustomEvent(PAGE_WILL_CHANGE_EVENT, {
detail: {
fromPageId,
toPageId: nextPageId,
},
}),
);
}
// ---- Accent Color System ----
@ -2309,6 +2324,8 @@ function initializeWatchlist() {
}
function navigateToPage(pageId, options = {}) {
navigationEpoch += 1;
if (!options.forceReload && pageId === currentPage) return;
// Permission guard — redirect to home page if not allowed
@ -2322,11 +2339,25 @@ function navigateToPage(pageId, options = {}) {
const router = getWebRouter();
if (router && !options.skipRouteChange) {
notifyPageWillChange(pageId);
const route = router.routeManifest?.find((entry) => entry.pageId === pageId);
if (route?.kind === 'react') {
showReactHost(pageId);
setActivePageChrome(pageId);
}
return router.navigateToPage(pageId, { replace: options.replace === true });
}
// Fallback path for initial bootstrap or environments without TanStack routing.
activatePage(pageId, { forceReload: options.forceReload === true });
const route = router?.routeManifest?.find((entry) => entry.pageId === pageId);
notifyPageWillChange(pageId);
const legacyPageElement = document.getElementById(`${pageId}-page`);
if (route?.kind === 'react' || !legacyPageElement) {
showReactHost(pageId);
setActivePageChrome(pageId);
} else {
activatePage(pageId, { forceReload: options.forceReload === true });
}
if (!options.skipPushState) {
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;

View file

@ -700,6 +700,27 @@ let artistDetailPageState = {
enhancedTrackSort: {}
};
function clearArtistDetailPageState() {
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.currentArtistSource = null;
artistDetailPageState.originStack = [];
}
if (typeof window !== 'undefined') {
window.addEventListener(PAGE_WILL_CHANGE_EVENT, (event) => {
const detail = event.detail || {};
if (detail.fromPageId === 'artist-detail' && detail.toPageId !== 'artist-detail') {
clearArtistDetailPageState();
}
});
}
// Discography filter state
let discographyFilterState = {
categories: { albums: true, eps: true, singles: true },

View file

@ -1161,6 +1161,9 @@ async function startDownload(index) {
async function loadInitialData() {
try {
const initialPath = window.location.pathname;
const initialNavigationEpoch = navigationEpoch;
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
@ -1177,6 +1180,10 @@ async function loadInitialData() {
? urlPage
: homePage;
if (window.location.pathname !== initialPath || navigationEpoch !== initialNavigationEpoch) {
return;
}
// Always apply the target page to the legacy shell chrome.
const router = getWebRouter();
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);

View file

@ -61,6 +61,7 @@ function activateLegacyPath(pathname) {
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
@ -77,6 +78,7 @@ function syncActivePageFromLocation() {
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);

View file

@ -13,10 +13,11 @@ async function selectProfile(page: Page, baseURL: string, profileId = 1) {
async function waitForShellRoute(page: Page, pageId: string) {
if (pageId === 'issues') {
await expect
.poll(async () =>
page.evaluate(() => document.querySelector('.page.active')?.id ?? ''),
)
.poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), {
timeout: 15000,
})
.toBe('webui-react-root');
await expect(page.getByTestId('issues-board')).toBeVisible({ timeout: 15000 });
return;
}
@ -106,3 +107,27 @@ test('browser history restores top-level routes', async ({ page, baseURL }) => {
await waitForShellRoute(page, 'issues');
await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/);
});
test('browser history leaves artist detail when going back to library', async ({
page,
baseURL,
}) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
await page.goto(new URL('/library', baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, 'library');
await expect.poll(async () => page.locator('.library-artist-card').count()).toBeGreaterThan(0);
await page.locator('.library-artist-card').first().click();
await waitForShellRoute(page, 'artist-detail');
await expect(page).toHaveURL(/\/artist-detail$/);
await page.goBack();
await waitForShellRoute(page, 'library');
await expect(page).toHaveURL(/\/library$/);
});