fix(workspace): lead header Back to a parent route when the page opened the session (#1830)

* fix(workspace): lead header Back to a parent route when the page opened the session

Settings, Discover, actor and in-portal search registered a header Back
that only ran Location.back(). As the first entry of the session (deep
link, reload, restored view) that did nothing in Electron and left the
app in a browser.

WorkspaceBackNavigationService.back(resolveParent) keeps Location.back()
while the previous entry is an in-app one, and while that is unknown
because the Navigation API is missing. Otherwise it opens the page's
parent with replaceUrl, so history Back cannot return to the page:

- Settings: the first workspace view (resolveDashboardPath()).
- Discover: the catalog section it lists (vod for movies, series for TV).
- Actor and search: the portal root, which redirects to its default
  section within the same navigation.

The web E2E opens these pages in a fresh tab: a page.goto in the same
tab leaves the previous document behind, often at the parent's URL, so
history Back passed without the fix. Electron covers settings after a
window reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): lead first-entry Back to the parent without the Navigation API

Review follow-ups (Greptile):

- Without the Navigation API (older Safari and Firefox) back() always
  called Location.back(), so a page that opened the session still left
  the app. The service now tracks the router's in-app history depth there
  (trackRouterHistoryDepth): first navigation 0, push +1, replacement
  keeps it, a traversal restores the depth recorded for its entry. Depth
  0 opens the parent; an unknown depth (an entry from before a reload)
  keeps Location.back().
- Stalker's Discover (movie/tv section), actor and search pages now have
  tests that they hand the service the parent under the portal :id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workspace): adopt the router navigation the Back depth tracker missed

Review follow-up (Codex, Greptile): the lazy workspace shell creates the
Back service after the first NavigationStart, so the tracker saw only its
NavigationEnd, left the depth unknown and counted the next push as the
first entry. It now adopts the router's current or last successful
navigation when it starts: a first navigation is depth 0, a later one
leaves the depth unknown (browser history Back), and a late start of the
adopted navigation is not counted again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-07 00:24:00 +02:00
1 parent 93c5f1a051
commit acb8cb0318
23 files changed
+940 -55

No files matched your search

@@ -0,0 +1,84 @@
import type { DestroyRef } from '@angular/core';
import {
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationStart,
type Router,
} from '@angular/router';
/**
* In-app history depth from router events, for runtimes without the
* Navigation API (older Safari and Firefox), where the browser does not say
* whether the previous entry belongs to this app session.
*
* The document's first navigation is depth 0, a push adds one, a replacement
* or a navigation that skips the location keeps the depth, and a traversal
* restores the depth recorded for the entry it returns to. Entries from
* before a reload were recorded by another document, so a traversal to one
* leaves the depth unknown (null).
*/
export function trackRouterHistoryDepth(
router: Pick<
Router,
'events' | 'currentNavigation' | 'lastSuccessfulNavigation'
>,
destroyRef: Pick<DestroyRef, 'onDestroy'>
): () => number | null {
let depth: number | null = null;
let pending: number | null = null;
let started = false;
const depthByNavigationId = new Map<number, number>();
// A Router without events (a partial test double) leaves the depth
// unknown, which keeps browser history Back.
if (!router.events) return () => null;
// The tracker can start after the first navigation began: the workspace
// shell that creates it is lazy. Adopt the router's state for the events
// it missed. Only a first navigation has a known depth (0); a later one
// leaves it unknown, which keeps browser history Back.
const inFlight = router.currentNavigation?.() ?? null;
const last = router.lastSuccessfulNavigation?.() ?? null;
const adoptedId = inFlight?.id ?? null;
if (inFlight) {
started = true;
pending = inFlight.previousNavigation === null ? 0 : null;
} else if (last) {
started = true;
depth = last.previousNavigation === null ? 0 : null;
if (depth !== null) depthByNavigationId.set(last.id, depth);
}
const subscription = router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
// Already adopted above; its start may still be on its way.
if (event.id === adoptedId) return;
if (!started) {
pending = 0;
} else if (event.navigationTrigger === 'popstate') {
const restoredId = event.restoredState?.navigationId;
pending =
restoredId === undefined
? null
: (depthByNavigationId.get(restoredId) ?? null);
} else {
const extras = router.currentNavigation()?.extras;
pending =
extras?.replaceUrl || extras?.skipLocationChange
? depth
: (depth ?? 0) + 1;
}
started = true;
} else if (event instanceof NavigationEnd) {
depth = pending;
if (depth !== null) depthByNavigationId.set(event.id, depth);
} else if (
event instanceof NavigationCancel ||
event instanceof NavigationError
) {
pending = depth;
}
});
destroyRef.onDestroy(() => subscription.unsubscribe());
return () => depth;
}
@@ -1,6 +1,13 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
Event as RouterEvent,
NavigationEnd,
NavigationStart,
Router,
} from '@angular/router';
import { Subject } from 'rxjs';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import {
WORKSPACE_HISTORY_NAVIGATION,
@@ -42,15 +49,65 @@ class FakeHistory extends EventTarget {
describe('WorkspaceBackNavigationService', () => {
const back = jest.fn();
const navigate = jest.fn().mockResolvedValue(true);
const navigateByUrl = jest.fn().mockResolvedValue(true);
let routerEvents = new Subject<RouterEvent>();
/** The parts of a router Navigation the depth tracker reads. */
type NavigationLike = {
id?: number;
previousNavigation?: unknown;
extras: { replaceUrl?: boolean };
};
const currentNavigation = signal<NavigationLike | null>(null);
const lastSuccessfulNavigation = signal<NavigationLike | null>(null);
let navigationId = 0;
/** A router navigation as the history depth tracker sees it. */
function routerNavigation(
options: {
popstateTo?: number;
replaceUrl?: boolean;
} = {}
): number {
const id = ++navigationId;
currentNavigation.set({ extras: { replaceUrl: options.replaceUrl } });
routerEvents.next(
new NavigationStart(
id,
`/page-${id}`,
options.popstateTo === undefined ? 'imperative' : 'popstate',
options.popstateTo === undefined
? null
: { navigationId: options.popstateTo }
)
);
routerEvents.next(new NavigationEnd(id, `/page-${id}`, `/page-${id}`));
currentNavigation.set(null);
return id;
}
function createService(
history: FakeHistory | null = null
): WorkspaceBackNavigationService {
back.mockReset();
navigate.mockClear();
navigateByUrl.mockClear();
routerEvents = new Subject<RouterEvent>();
navigationId = 0;
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: Location, useValue: { back } },
{
provide: Router,
useValue: {
navigate,
navigateByUrl,
events: routerEvents,
currentNavigation,
lastSuccessfulNavigation,
},
},
{
provide: WORKSPACE_HISTORY_NAVIGATION,
useValue: history as unknown as WorkspaceHistoryNavigation,
@@ -60,6 +117,11 @@ describe('WorkspaceBackNavigationService', () => {
return TestBed.inject(WorkspaceBackNavigationService);
}
afterEach(() => {
currentNavigation.set(null);
lastSuccessfulNavigation.set(null);
});
function createTarget(run = jest.fn()): WorkspaceBackTarget {
return {
label: signal<string | null>(null),
@@ -202,4 +264,213 @@ describe('WorkspaceBackNavigationService', () => {
);
});
});
describe('back to a parent', () => {
it('goes back in history while the previous entry is in-app', () => {
const history = new FakeHistory();
const service = createService(history);
history.push();
const parent = jest.fn(() => '/workspace/dashboard');
service.back(parent);
expect(back).toHaveBeenCalledTimes(1);
expect(parent).not.toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
it('opens the parent in place of a page that opened the session', async () => {
const service = createService(new FakeHistory());
service.back(() => '/workspace/dashboard');
await Promise.resolve();
// Replacing keeps history Back from returning to the page.
expect(navigateByUrl).toHaveBeenCalledWith('/workspace/dashboard', {
replaceUrl: true,
});
expect(back).not.toHaveBeenCalled();
});
it('opens the parent after a reload, whose old entries do not count', async () => {
const service = createService(new FakeHistory([false, true]));
service.back(() => ['/workspace', 'xtreams', 'pl/1', 'vod']);
await Promise.resolve();
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'xtreams', 'pl/1', 'vod'],
{ replaceUrl: true }
);
expect(back).not.toHaveBeenCalled();
});
it('waits for a parent that resolves asynchronously', async () => {
const service = createService(new FakeHistory());
service.back(() => Promise.resolve('/workspace/sources'));
await Promise.resolve();
await Promise.resolve();
expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', {
replaceUrl: true,
});
});
it('keeps browser history when the page knows no parent', async () => {
const service = createService(new FakeHistory());
service.back(() => null);
await Promise.resolve();
expect(back).toHaveBeenCalledTimes(1);
expect(navigate).not.toHaveBeenCalled();
expect(navigateByUrl).not.toHaveBeenCalled();
});
describe('without the Navigation API', () => {
// The router's history depth decides there (older Safari and
// Firefox): a page that opened the session must not leave the app.
it('opens the parent of the page that opened the session', async () => {
const service = createService(null);
routerNavigation();
service.back(() => '/workspace/dashboard');
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigateByUrl).toHaveBeenCalledWith(
'/workspace/dashboard',
{ replaceUrl: true }
);
});
it('goes back in history after the router pushed a page', () => {
const service = createService(null);
routerNavigation();
routerNavigation();
const parent = jest.fn(() => '/workspace/dashboard');
service.back(parent);
expect(back).toHaveBeenCalledTimes(1);
expect(parent).not.toHaveBeenCalled();
});
it('treats a replacement as the same entry', async () => {
const service = createService(null);
routerNavigation();
routerNavigation({ replaceUrl: true });
service.back(() => ['/workspace', 'sources']);
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith(
['/workspace', 'sources'],
{ replaceUrl: true }
);
});
it('opens the parent again after Back returned to the first page', async () => {
const service = createService(null);
const first = routerNavigation();
routerNavigation();
routerNavigation({ popstateTo: first });
service.back(() => '/workspace/dashboard');
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigateByUrl).toHaveBeenCalledTimes(1);
});
// The lazy workspace shell creates the service after the first
// navigation started, so the tracker adopts the router's state.
it('adopts a first navigation that ended before the service existed', async () => {
lastSuccessfulNavigation.set({
id: 1,
previousNavigation: null,
extras: {},
});
const service = createService(null);
service.back(() => '/workspace/dashboard');
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigateByUrl).toHaveBeenCalledTimes(1);
});
it('adopts a first navigation whose start fired before the service existed', async () => {
currentNavigation.set({
id: 1,
previousNavigation: null,
extras: {},
});
const service = createService(null);
navigationId = 1;
// Only the end reaches the tracker.
routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1'));
currentNavigation.set(null);
service.back(() => '/workspace/dashboard');
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigateByUrl).toHaveBeenCalledTimes(1);
});
it('adopts a first navigation still in flight and counts the next push', async () => {
currentNavigation.set({
id: 1,
previousNavigation: null,
extras: {},
});
const service = createService(null);
navigationId = 1;
// Its start arrives late; the end commits depth 0.
routerEvents.next(new NavigationStart(1, '/page-1'));
routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1'));
currentNavigation.set(null);
service.back(() => '/workspace/dashboard');
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(navigateByUrl).toHaveBeenCalledTimes(1);
routerNavigation();
service.back(() => '/workspace/dashboard');
expect(back).toHaveBeenCalledTimes(1);
});
it('keeps browser history when created after several navigations', () => {
lastSuccessfulNavigation.set({
id: 3,
previousNavigation: { id: 2 },
extras: {},
});
const service = createService(null);
const parent = jest.fn(() => '/workspace/dashboard');
service.back(parent);
expect(back).toHaveBeenCalledTimes(1);
expect(parent).not.toHaveBeenCalled();
});
it('keeps browser history on an entry from before a reload', () => {
const service = createService(null);
routerNavigation();
// Restores an entry recorded by the previous document.
routerNavigation({ popstateTo: 42 });
const parent = jest.fn(() => '/workspace/dashboard');
service.back(parent);
expect(back).toHaveBeenCalledTimes(1);
expect(parent).not.toHaveBeenCalled();
});
});
});
});
@@ -7,7 +7,9 @@ import {
InjectionToken,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { trackRouterHistoryDepth } from './router-history-depth';
/** The parts of the browser's Navigation API the history fallback reads. */
export type WorkspaceHistoryNavigation = Pick<
@@ -28,6 +30,12 @@ export const WORKSPACE_HISTORY_NAVIGATION =
}
);
/**
* Where a page's Back leads when there is no in-app history: a URL or router
* commands. Null keeps browser history.
*/
export type WorkspaceBackParent = string | readonly string[] | null;
/**
* True when the previous history entry belongs to this document, i.e. the
* router pushed it in this app session. Entries from before a reload or from
@@ -49,8 +57,14 @@ function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean {
@Injectable({ providedIn: 'root' })
export class WorkspaceBackNavigationService {
private readonly location = inject(Location);
private readonly router = inject(Router);
private readonly history = inject(WORKSPACE_HISTORY_NAVIGATION);
private readonly targets = signal<readonly WorkspaceBackTarget[]>([]);
private readonly canGoBackInApp = signal(false);
/** In-app history depth where the Navigation API is missing. */
private readonly routerDepth = this.history
? () => null
: trackRouterHistoryDepth(this.router, inject(DestroyRef));
/**
* Generic Back to the previous page. It advertises no Escape (no page
@@ -73,7 +87,7 @@ export class WorkspaceBackNavigationService {
});
constructor() {
const history = inject(WORKSPACE_HISTORY_NAVIGATION);
const history = this.history;
if (!history) return;
// Fires for router pushes and replacements and for traversals,
// including a guard-cancelled Back that the router rewrites.
@@ -86,6 +100,29 @@ export class WorkspaceBackNavigationService {
);
}
/**
* Back for a page with a parent route. Browser history while the previous
* entry is an in-app one. Otherwise the page opened the session (deep
* link, reload, restored view), where `Location.back()` would do nothing
* in Electron or leave the app in a browser: the parent replaces the
* current entry, so history Back cannot return to the page just left.
* Without the Navigation API the router's history depth decides, and an
* unknown depth (a traversal to an entry from before a reload) keeps
* browser history, which then has a previous entry.
*/
back(
resolveParent: () => WorkspaceBackParent | Promise<WorkspaceBackParent>
): void {
const inApp = this.history
? this.canGoBackInApp()
: (this.routerDepth() ?? 1) > 0;
if (inApp) {
this.location.back();
return;
}
void this.openParent(resolveParent);
}
/**
* Returns the release function. It removes only this target: when one
* page replaces another (a loading shell by the loaded one), creation and
@@ -109,4 +146,17 @@ export class WorkspaceBackNavigationService {
target.run();
return true;
}
private async openParent(
resolveParent: () => WorkspaceBackParent | Promise<WorkspaceBackParent>
): Promise<void> {
const parent = await resolveParent();
if (parent === null) {
this.location.back();
} else if (typeof parent === 'string') {
await this.router.navigateByUrl(parent, { replaceUrl: true });
} else {
await this.router.navigate([...parent], { replaceUrl: true });
}
}
}