From 78587f95ba9d090d7a8a92f2d195307809026262 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:45:50 +0200 Subject: [PATCH] fix(portals): return from a portal handoff without losing the collection view (#1435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(portals): return from a portal handoff without losing the collection view "View in portal" left `stalkerReturnTo` pointing at the collection URL, and the portal detail's back affordance re-navigated there with `navigateByUrl()`. That starts a stateless history entry, but the collection's active tab, scope and open inline detail live only in `window.history.state` — so back landed on the default `live` tab with the title closed, and the portal page stayed one browser Back away. The handoff now also sets `stalkerReturnByHistory`, and both Stalker back handlers step back a single history entry instead. That entry is the one the handoff itself pushed, so it restores the collection exactly as the user left it and adds nothing to the history stack. The flag is set only by this builder and only alongside `returnTo`, so the dashboard handoff and every other `stalkerReturnTo` caller keeps re-navigating. Reported by Codex on #1422 after it merged. Co-Authored-By: Claude Fable 5 * fix(portals): scope the history-return marker to its own handoff `openStalkerItem` is consumed on arrival, but `stalkerReturnTo` and the new `stalkerReturnByHistory` stay on the history entry, and a Stalker detail opens in place without pushing one. So after Back + browser Forward the same entry can host a different title, whose back affordance would follow the leftover marker out to the collection instead of just closing it. The marker now carries the handed-off item's identity instead of a bare `true`, and a marker that does not match the open title is treated as stale: it suppresses the whole return contract, so back simply closes the detail. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 * refactor(portals): share the Stalker back-navigation decision Both back handlers duplicated the marker/`returnTo` precedence verbatim, and the catalog view kept its own copy of the identity normalization the marker binding mirrors — two places for one rule to drift. `resolveStalkerBackNavigation()` now owns the decision and both handlers just apply it, while `stalkerItemIdentity()` delegates to the shared `normalizeStalkerHandoffIdentity()`. Behaviour is unchanged; the precedence gains direct unit coverage instead of only being exercised through the two components. Follow-up to Greptile's review notes on #1435. Co-Authored-By: Claude Fable 5 * fix(portals): make the return marker one-shot and id-shape agnostic Two defects in the marker binding, both reported by Codex on #1435: The comparison identity read only `item.id`, but the marker is built from `extractStalkerItemId()`, which also accepts `stream_id`/`series_id`/ `movie_id`. A collection row carrying only `movie_id` therefore compared against an empty identity, and since a marker was present the handler returned without stepping back or re-navigating — the back affordance simply stopped working. It now follows the same field order. Binding also only fixed a *different* title reopened on the entry: selecting the original title again still matched. Honouring the marker now retires both return keys from the entry, so a browser Forward cannot replay the handoff. Co-Authored-By: Claude Fable 5 * fix(portals): bind the return marker to the id the detail can report The previous attempt widened the comparison to `extractStalkerItemId()`'s field set, but `buildStalkerSelectedVodItem()` — which every opened detail goes through — derives `id` from `id ?? stream_id` and drops `series_id`/`movie_id`. The wider lookup therefore ran after the lossy normalization and still could not match, leaving the back affordance doing nothing for those rows. The marker is now bound to the identity the detail will actually report, and a row whose id cannot survive normalization gets no marker at all: the handoff falls back to re-navigating via `stalkerReturnTo`, which works. The earlier regression tests hid this by injecting a `movie_id`-shaped selection directly, which normalization can never produce; they now build the selection through `buildStalkerSelectedVodItem()`. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 * fix(portals): retire the return contract after a native browser Back Leaving the handed-off portal entry with the browser's own Back runs no back affordance, so nothing consumed the marker. A Forward replay then reopened the catalog with the contract intact, and opening the same title again matched the identity — its Back exited to the collection instead of closing the freshly opened detail. `CategoryContentViewComponent` now retires the contract whenever it lands on the entry with no handoff item and no detail open: the handoff is over, so anything opened from the list afterwards is a fresh selection. The guard on an open detail keeps arrival itself from retiring a contract it still needs. Also documents, per Greptile, that a `none` decision still closes the detail and only suppresses the navigation. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 * fix(portals): keep alternate-id rows on the history return The previous revision let a row carrying only `movie_id`/`series_id` fall back to re-navigating, which reproduced the exact defect this PR exists to fix: the new entry has no `collectionViewState`, so the collection reopens on its default tab with the title closed. The builder now pins the resolved id onto the handoff state item when the raw row carries neither `id` nor `stream_id`, so `buildStalkerSelectedVodItem()` reports an identity the marker can bind to and those rows get the same history return as every other one. An existing id is never overwritten. Also scopes the arrival-side retirement to handoffs that actually set the marker, so a plain `stalkerReturnTo` caller such as the dashboard keeps its behaviour — I had made that unconditional, which contradicted the scope this PR claims. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 * refactor(portals): drop a redundant guard in the identity normalizer `split(':')` always yields at least one element, and the workspace does not enable `noUncheckedIndexedAccess`, so the optional chain and `?? ''` fallback were unreachable rather than type-required. Behaviour is unchanged. Spotted by Greptile on #1435. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .changes/portals-view-in-portal-back.md | 8 + CLAUDE.md | 28 ++- docs/architecture/portal-detail-navigation.md | 31 ++- .../category-content-view.component.spec.ts | 70 +++++- .../category-content-view.component.ts | 26 ++- ...ollection-detail-portal-navigation.spec.ts | 187 ++++++++++++++- .../collection-detail-portal-navigation.ts | 173 +++++++++++++- .../stalker-catalog-detail.component.spec.ts | 117 +++++++++- .../stalker-catalog-detail.component.ts | 22 +- ...talker-collection-detail.component.spec.ts | 11 +- ...talker-series-view.back-navigation.spec.ts | 212 ++++++++++++++++++ .../stalker-series-view.component.ts | 22 +- 12 files changed, 875 insertions(+), 32 deletions(-) create mode 100644 .changes/portals-view-in-portal-back.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts diff --git a/.changes/portals-view-in-portal-back.md b/.changes/portals-view-in-portal-back.md new file mode 100644 index 000000000..e44ee31d0 --- /dev/null +++ b/.changes/portals-view-in-portal-back.md @@ -0,0 +1,8 @@ +--- +type: fix +area: portals +--- + +Going back from a Stalker title you reached with "View in portal" now returns +you to exactly where you were — the same favorites or history tab, with the +title still open — instead of dropping you on the Live TV tab. diff --git a/CLAUDE.md b/CLAUDE.md index 43788a913..e55ed6af3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1257,7 +1257,33 @@ engine` (restart required) or a `/series` route leaves the detail unable to load episodes. The virtual `series` category is normalized to `vod` the same way `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries - `stalkerReturnTo`. + `stalkerReturnTo` plus + `stalkerReturnByHistory`, and the portal detail's back affordance + (`StalkerCatalogDetailComponent.onVodBack()`, + `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back + one history entry instead of calling `navigateByUrl()`. The collection's + active tab, scope and open inline detail live only in `window.history.state` + (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would + reopen it on the default `live` tab and leave the portal page one browser + Back away. The marker carries the handed-off item's identity, not a bare + `true`: `openStalkerItem` is consumed on arrival while the return keys stay + on the entry, and a Stalker detail opens in place without pushing one — so + after Back + browser Forward the same entry can host a different title, whose + back affordance must just close it. A stale marker suppresses the whole + return contract, and honouring it retires both keys from the entry so a + browser Forward cannot replay them for a reopened title. Leaving with the + browser's own Back runs no affordance, so `CategoryContentViewComponent` + also retires the contract whenever it lands on the entry with no handoff + item and no open detail. That retirement is gated on the marker, so a plain + `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is + restricted to what `buildStalkerSelectedVodItem()` preserves (`id ?? +stream_id`); it drops `series_id`/`movie_id`, so the builder pins the + resolved id onto the handoff state item when the raw row carries neither — + those rows then get the same history return instead of degrading to a + re-navigation that resets the collection's tab. + Only this builder sets the marker, so the + dashboard handoff and any other `stalkerReturnTo` caller keeps + re-navigating. - Unlike the download handoff this bridge does NOT pass `detailPresentation: 'provider-only'` — the item exists in the provider catalog, so the full normal detail (downloads included) is wanted. diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 894e66989..e45fbcb93 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -53,7 +53,36 @@ Related: download handoff it does NOT pass `detailPresentation: 'provider-only'` — the item exists in the provider catalog and the full normal detail is desired. The Stalker handoff carries `stalkerReturnTo` so the portal detail's back - affordance returns to the originating collection. + affordance returns to the originating collection, plus + `stalkerReturnByHistory` so that return is a single history step rather than + a fresh `navigateByUrl()`. The collection's active tab, scope and open inline + detail live only in `window.history.state` (`collectionViewState` / + `openCollectionDetailItem`); re-navigating starts a stateless entry, which + reopened the collection on its default `live` tab and left the portal page + one browser Back away. The marker carries the handed-off item's identity + rather than a bare `true`, because `openStalkerItem` is consumed on arrival + while the return keys stay on the entry and a Stalker detail opens in place + without pushing one: after Back + browser Forward the same entry can host a + different title, and that title's back affordance must close it rather than + exit to the collection. A marker that does not match the open item is stale + and suppresses the whole return contract. Honouring it retires both return + keys from the entry, so the handoff is genuinely one-shot: a browser Forward + onto the same entry cannot replay it for a title reopened from the catalog. + Leaving via the browser's own Back runs no affordance at all, so + `CategoryContentViewComponent` retires the contract as well whenever it + lands on the entry with no handoff item and no detail open — the handoff is + over, and anything opened from the list afterwards is a fresh selection. It + is gated on the marker's presence, so a plain `stalkerReturnTo` caller such + as the dashboard handoff keeps its existing behaviour untouched. + The identity is restricted to the fields `buildStalkerSelectedVodItem()` + preserves (`id ?? stream_id`) — it drops `series_id`/`movie_id`, so binding + to the wider `extractStalkerItemId()` set would compare against an identity + the opened detail can no longer report and silently strand the affordance. + A row identified only by those alternate fields would open with an empty + identity, so the builder pins the resolved id onto the handoff state item + and those rows keep the history return as well. The marker is set only by this + builder and only alongside `returnTo`, so the dashboard handoff and every + other `stalkerReturnTo` caller keeps its re-navigating behaviour. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index 026c96fad..a2e4e4918 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -373,6 +373,72 @@ describe('CategoryContentViewComponent', () => { expect(window.history.state).toEqual({ preserved: 'value' }); }); + it('retires a return marker that outlived its handoff item', async () => { + // Leaving the entry with the browser's own Back never runs a back + // affordance, so nothing retired the contract; a Forward replay lands + // here with the marker but no handoff item and no open detail. + catalog.provider = 'stalker'; + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + preserved: 'value', + }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(window.history.state).toEqual({ preserved: 'value' }); + }); + + it('leaves a plain stalkerReturnTo handoff alone', async () => { + // The dashboard handoff sets no history-back marker and keeps its + // pre-existing re-navigating behaviour. + catalog.provider = 'stalker'; + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(window.history.state).toEqual({ + stalkerReturnTo: '/workspace/dashboard', + }); + }); + + it('keeps the return marker while the handoff detail is being opened', async () => { + const item = { id: '42', category_id: 'vod' }; + catalog.provider = 'stalker'; + catalog.selectItem.mockImplementation((selected) => { + selectedItem.set(selected); + return null; + }); + window.history.replaceState( + { + openStalkerItem: item, + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '', + window.location.href + ); + + fixture.detectChanges(); + await fixture.whenStable(); + + // The contract must survive arrival — the back affordance consumes it. + expect(window.history.state).toEqual({ + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }); + }); + it('does not retain the consumed provider-only presentation across identity, regular-open, or route changes', async () => { const item = { id: '42', category_id: 'vod' }; catalog.provider = 'stalker'; @@ -500,9 +566,7 @@ describe('CategoryContentViewComponent', () => { infiniteFixture.detectChanges(); - expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes( - 1 - ); + expect(catalog.consumeSavedScrollPosition).toHaveBeenCalledTimes(1); const grid = infiniteFixture.nativeElement.querySelector( 'app-grid-list' diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 5a5cf175f..33f4e311b 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -26,8 +26,11 @@ import { } from '@iptvnator/portal/shared/ui'; import { clearNavigationStateKeys, + consumeStalkerReturnMarker, getOpenStalkerItemState, + getStalkerReturnByHistoryState, isProviderOnlyDetailState, + normalizeStalkerHandoffIdentity, PortalCatalogFacade, OPEN_STALKER_ITEM_STATE_KEY, PORTAL_CATALOG_DETAIL_COMPONENT, @@ -333,6 +336,21 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { const item = getOpenStalkerItemState(window.history.state); if (!item) { + // The handoff item is gone, but its return contract can still sit + // on this entry: leaving with the browser's own Back never runs a + // back affordance, so nothing retired it. Landing here with no + // detail open means the handoff is over — any title opened from + // the list now is a fresh selection whose Back must close it + // rather than exit to the collection. + // Scoped to collection handoffs, which are the only ones that + // set the history-back marker: a plain `stalkerReturnTo` caller + // (the dashboard) keeps its existing re-navigating behaviour. + if ( + !this.selectedItem() && + getStalkerReturnByHistoryState(window.history.state) + ) { + consumeStalkerReturnMarker(); + } return; } @@ -359,10 +377,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { ): string | null { const rawId = item?.id ?? item?.series_id ?? item?.movie_id ?? item?.stream_id; - const normalized = String(rawId ?? '') - .trim() - .split(':')[0] - ?.trim(); - return normalized || null; + // Shared with the return-marker binding in + // `resolveStalkerBackNavigation()` so the two cannot drift apart. + return normalizeStalkerHandoffIdentity(rawId) || null; } } diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts index c46dc19a4..65b5f07df 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.spec.ts @@ -1,5 +1,10 @@ import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface'; -import { getUnifiedCollectionDetailNavigation } from './collection-detail-portal-navigation'; +import { + getStalkerReturnByHistoryState, + getUnifiedCollectionDetailNavigation, + isStalkerReturnByHistoryFor, + resolveStalkerBackNavigation, +} from './collection-detail-portal-navigation'; describe('getUnifiedCollectionDetailNavigation', () => { const xtreamMovie: UnifiedCollectionItem = { @@ -217,6 +222,127 @@ describe('getUnifiedCollectionDetailNavigation', () => { ); }); + it('binds the history-return marker to the handed-off item', () => { + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'movie-5' + ); + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-5' }) + ).toBe(true); + // A lazy episode id keeps its parent identity. + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-5:12' }) + ).toBe(true); + // Any other title on the same history entry is a stale match. + expect( + isStalkerReturnByHistoryFor(navigation?.state, { id: 'movie-9' }) + ).toBe(false); + expect(isStalkerReturnByHistoryFor(navigation?.state, undefined)).toBe( + false + ); + }); + + it('pins a usable id so an alternate-id row still gets history return', () => { + // buildStalkerSelectedVodItem() derives `id` from `id ?? stream_id`, + // so a movie_id-only row would open with an empty identity and the + // marker would have nothing to bind to. Pinning the resolved id keeps + // these rows on the history return instead of degrading to a + // re-navigation that resets the collection's tab. + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + stalkerItem: { + movie_id: 'movie-5', + title: 'Movie Five', + } as never, + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'movie-5' + ); + expect(navigation?.state?.['openStalkerItem']).toEqual( + expect.objectContaining({ id: 'movie-5', movie_id: 'movie-5' }) + ); + }); + + it('leaves an existing id on the state item untouched', () => { + const navigation = getUnifiedCollectionDetailNavigation( + { + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + stalkerItem: { + id: 'raw-id', + title: 'Movie Five', + } as never, + }, + { returnTo: '/workspace/global-recent' } + ); + + expect(navigation?.state?.['openStalkerItem']).toEqual( + expect.objectContaining({ id: 'raw-id' }) + ); + expect(getStalkerReturnByHistoryState(navigation?.state)).toBe( + 'raw-id' + ); + }); + + it('does not mark the handoff when no returnTo is supplied', () => { + const navigation = getUnifiedCollectionDetailNavigation({ + uid: 'stalker::stalker-1::movie-5', + name: 'Movie Five', + contentType: 'movie', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker Playlist', + stalkerId: 'movie-5', + }); + + expect(getStalkerReturnByHistoryState(navigation?.state)).toBeNull(); + }); + + it('does not treat other navigation state as history-returnable', () => { + expect(getStalkerReturnByHistoryState(null)).toBeNull(); + expect(getStalkerReturnByHistoryState(undefined)).toBeNull(); + expect( + getStalkerReturnByHistoryState({ + stalkerReturnTo: '/workspace/dashboard', + }) + ).toBeNull(); + // A non-string or blank marker carries no identity to bind to. + expect( + getStalkerReturnByHistoryState({ stalkerReturnByHistory: true }) + ).toBeNull(); + expect( + getStalkerReturnByHistoryState({ stalkerReturnByHistory: ' ' }) + ).toBeNull(); + }); + it('returns null for live and m3u items', () => { expect( getUnifiedCollectionDetailNavigation({ @@ -236,3 +362,62 @@ describe('getUnifiedCollectionDetailNavigation', () => { ).toBeNull(); }); }); + +describe('resolveStalkerBackNavigation', () => { + const handoff = { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: 'movie-5', + }; + + it('steps back for the title its marker was bound to', () => { + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-5' }) + ).toEqual({ kind: 'history-back' }); + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-5:3' }) + ).toEqual({ kind: 'history-back' }); + }); + + it('matches the id shape buildStalkerSelectedVodItem() produces', () => { + // That normalizer derives `id` from `id ?? stream_id` only, so those + // are the sole fields the opened detail can still report. + expect( + resolveStalkerBackNavigation(handoff, { stream_id: 'movie-5' }) + ).toEqual({ kind: 'history-back' }); + // series_id/movie_id do not survive normalization, so the builder + // pins a usable `id` instead of binding to them; a selection that + // still reports only movie_id cannot match. + expect( + resolveStalkerBackNavigation(handoff, { movie_id: 'movie-5' }) + ).toEqual({ kind: 'none' }); + }); + + it('suppresses the whole contract for a stale marker', () => { + // Gating only the history step would let the equally stale + // `stalkerReturnTo` re-navigate and produce the same unexpected exit. + expect( + resolveStalkerBackNavigation(handoff, { id: 'movie-9' }) + ).toEqual({ kind: 'none' }); + expect(resolveStalkerBackNavigation(handoff, undefined)).toEqual({ + kind: 'none', + }); + }); + + it('re-navigates for a plain returnTo handoff', () => { + expect( + resolveStalkerBackNavigation( + { stalkerReturnTo: '/workspace/dashboard' }, + { id: 'movie-5' } + ) + ).toEqual({ kind: 'navigate', url: '/workspace/dashboard' }); + }); + + it('does nothing without any return target', () => { + expect(resolveStalkerBackNavigation({}, { id: 'movie-5' })).toEqual({ + kind: 'none', + }); + expect(resolveStalkerBackNavigation(null, { id: 'movie-5' })).toEqual({ + kind: 'none', + }); + }); +}); diff --git a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts index 040bd8178..cc5545366 100644 --- a/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/collection-detail-portal-navigation.ts @@ -3,6 +3,9 @@ import { buildStalkerDetailNavigationTarget, buildStalkerStateItem, buildXtreamNavigationTarget, + clearNavigationStateKeys, + getStalkerReturnToState, + STALKER_RETURN_TO_STATE_KEY, WorkspaceNavigationTarget, } from './workspace-portal-navigation'; import { @@ -10,6 +13,128 @@ import { StalkerPortalItem, } from '@iptvnator/shared/interfaces'; +/** + * Marks a Stalker handoff whose origin is exactly one history entry back, so + * the portal detail's back affordance can step through history instead of + * re-navigating to `stalkerReturnTo`. + * + * The collection page keeps its active tab, scope and open inline detail only + * in `window.history.state` (`collectionViewState` / `openCollectionDetailItem`). + * `navigateByUrl()` starts a fresh entry without them, so the collection would + * come back on its default `live` tab and leave the portal page one browser + * Back away. Only this builder sets the marker, and only when it also supplies + * `returnTo`, so every other `stalkerReturnTo` caller keeps re-navigating. + * + * The value is the handed-off item's identity rather than a bare `true`: + * `openStalkerItem` is consumed on arrival, but the return keys stay on the + * history entry, and a Stalker detail opens in place without pushing one. So + * after a Back + browser Forward the same entry can host a *different* title, + * and an unbound marker would send that title's back affordance out to the + * collection instead of closing it. Binding scopes the whole return contract + * to the one title the handoff opened. + */ +export const STALKER_RETURN_BY_HISTORY_STATE_KEY = 'stalkerReturnByHistory'; + +/** + * Reads the marker above off a history/navigation state record, returning the + * item identity it is bound to, or `null` when absent/malformed. + */ +export function getStalkerReturnByHistoryState(state: unknown): string | null { + if (!state || typeof state !== 'object') { + return null; + } + + const raw = (state as Record)[ + STALKER_RETURN_BY_HISTORY_STATE_KEY + ]; + + return typeof raw === 'string' && raw.trim() ? raw.trim() : null; +} + +/** + * Normalizes a Stalker item id for marker comparison. Mirrors the catalog + * view's own `stalkerItemIdentity()`: a lazy episode id carries a `parent:child` + * suffix, and only the parent identifies the opened title. + */ +export function normalizeStalkerHandoffIdentity(value: unknown): string { + // `split` always yields at least one element, so the first is a string. + return String(value ?? '') + .trim() + .split(':')[0] + .trim(); +} + +/** + * Identity of a Stalker item for marker comparison, restricted to the fields + * that survive `buildStalkerSelectedVodItem()` — it derives `id` from + * `id ?? stream_id` and drops `series_id`/`movie_id`. Comparing against the + * wider `extractStalkerItemId()` set would read an identity the opened detail + * can no longer produce, so the marker would never match and the back + * affordance would silently do nothing. + */ +export function stalkerHandoffIdentityOf(item: unknown): string { + const raw = (item ?? {}) as Record; + + return normalizeStalkerHandoffIdentity(raw['id'] ?? raw['stream_id']); +} + +/** + * True when the history entry's return marker belongs to the currently opened + * item. A marker left over from an earlier handoff on the same entry is stale + * and must not drive the back affordance. + */ +export function isStalkerReturnByHistoryFor( + state: unknown, + selectedItem: unknown +): boolean { + const marker = getStalkerReturnByHistoryState(state); + const identity = stalkerHandoffIdentityOf(selectedItem); + + return Boolean(marker && identity && marker === identity); +} + +/** + * Retires the return contract from the current history entry. The handoff is + * one-shot: without this, browser Forward reopens the portal entry with the + * marker intact, and reopening the same title from the catalog would send its + * back affordance out to the collection instead of just closing it. + */ +export function consumeStalkerReturnMarker(): void { + clearNavigationStateKeys([ + STALKER_RETURN_BY_HISTORY_STATE_KEY, + STALKER_RETURN_TO_STATE_KEY, + ]); +} + +/** + * What a Stalker detail's back affordance should do, given the current history + * entry and the title it currently shows. Both back handlers share this so the + * marker/`returnTo` precedence cannot drift between them. + */ +export type StalkerBackNavigation = + | { kind: 'history-back' } + | { kind: 'navigate'; url: string } + | { kind: 'none' }; + +export function resolveStalkerBackNavigation( + state: unknown, + selectedItem: unknown +): StalkerBackNavigation { + // A collection handoff is exactly one entry back, and the collection's + // tab/scope/inline-detail live only on that entry — re-navigating would + // drop them and leave the portal page one browser Back away. The keys + // outlive the handoff though, so a marker bound to another title is stale + // and must suppress the whole contract: back then just closes the detail. + if (getStalkerReturnByHistoryState(state)) { + return isStalkerReturnByHistoryFor(state, selectedItem) + ? { kind: 'history-back' } + : { kind: 'none' }; + } + + const returnTo = getStalkerReturnToState(state); + return returnTo ? { kind: 'navigate', url: returnTo } : { kind: 'none' }; +} + /** * Builds the portal-detail target for a collection item, or `null` when no * exact detail route can be formed. Unlike `getUnifiedCollectionNavigation` @@ -52,19 +177,49 @@ export function getUnifiedCollectionDetailNavigation( type ); - return buildStalkerDetailNavigationTarget({ + const returnTo = options?.returnTo ?? null; + const stalkerId = item.stalkerId ?? lastUidSegment(item.uid) ?? ''; + const stateItem = buildStalkerStateItem(stalkerItem, { + id: stalkerId, + title: item.name, + type, + category_id: categoryId, + poster_url: item.posterUrl ?? item.logo ?? undefined, + }); + + // `buildStalkerStateItem()` keeps the raw row as-is, and + // `buildStalkerSelectedVodItem()` later derives `id` from + // `id ?? stream_id` only. A row carrying just `movie_id`/`series_id` + // would therefore open with an empty identity — nothing the marker + // could bind to. Pin the resolved id so those rows get the same + // history return as every other one instead of degrading to a + // re-navigation that resets the collection's tab. + if (!stalkerHandoffIdentityOf(stateItem)) { + const resolvedId = normalizeStalkerHandoffIdentity(stalkerId); + if (resolvedId) { + stateItem['id'] = resolvedId; + } + } + + const target = buildStalkerDetailNavigationTarget({ playlistId: item.playlistId, type, categoryId, - item: buildStalkerStateItem(stalkerItem, { - id: item.stalkerId ?? lastUidSegment(item.uid) ?? '', - title: item.name, - type, - category_id: categoryId, - poster_url: item.posterUrl ?? item.logo ?? undefined, - }), - returnTo: options?.returnTo ?? null, + item: stateItem, + returnTo, }); + + const handoffIdentity = stalkerHandoffIdentityOf(stateItem); + + return returnTo && handoffIdentity + ? { + ...target, + state: { + ...(target.state ?? {}), + [STALKER_RETURN_BY_HISTORY_STATE_KEY]: handoffIdentity, + }, + } + : target; } return null; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts index d0f4ac49a..041d23ab1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts @@ -1,3 +1,4 @@ +import { Location } from '@angular/common'; import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -16,6 +17,7 @@ import { } from '@iptvnator/services'; import { VodDetailsComponent } from '@iptvnator/ui/playback'; import { EMPTY, of } from 'rxjs'; +import { buildStalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access'; import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component'; @@ -68,6 +70,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { const contentType = signal<'vod' | 'series'>('vod'); const catalogPlaylist = signal({ id: 'stalker-1' }); const snackBar = { open: jest.fn() }; + const routerMock = { navigateByUrl: jest.fn() }; + const locationMock = { back: jest.fn() }; + const originalHistoryState = window.history.state; const selectedItem = signal({ id: '42', cmd: '/media/42', @@ -85,6 +90,8 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); catalogPlaylist.set({ id: 'stalker-1' }); snackBar.open.mockReset(); + routerMock.navigateByUrl.mockReset(); + locationMock.back.mockReset(); await TestBed.configureTestingModule({ imports: [StalkerCatalogDetailComponent], @@ -123,7 +130,8 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { useValue: { getPortalFavorites: jest.fn(() => of([])) }, }, { provide: DownloadsService, useValue: {} }, - { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: locationMock }, { provide: MatSnackBar, useValue: snackBar }, { provide: TranslateService, @@ -153,6 +161,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { afterEach(() => { fixture.destroy(); + window.history.replaceState(originalHistoryState, ''); }); it('passes provider-only mode to the matching regular VOD', async () => { @@ -319,4 +328,110 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { expect(fixture.componentInstance.inlinePlayback()).toBe(playback); }); + + it('steps back through history for a collection handoff', () => { + // The collection's tab, scope and open inline detail live only on the + // previous history entry; re-navigating would drop them. + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); + + it('still re-navigates for a plain stalkerReturnTo handoff', () => { + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard' + ); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('does not navigate when no return target is present', () => { + window.history.replaceState({}, ''); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('retires the return contract so a forward-replay cannot fire it again', () => { + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(window.history.state?.stalkerReturnByHistory).toBeUndefined(); + expect(window.history.state?.stalkerReturnTo).toBeUndefined(); + }); + + it('matches a selection whose id came from stream_id', () => { + // buildStalkerSelectedVodItem() derives `id` from `id ?? stream_id`, + // so that is the only shape the opened detail can report here. + selectedItem.set( + buildStalkerSelectedVodItem({ + stream_id: '42', + cmd: '/media/42', + } as never) + ); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + it('ignores a marker left over from an earlier handoff on this entry', () => { + // The return keys outlive the handoff, and a Stalker detail opens in + // place — so a later title on the same entry must just close. + selectedItem.set({ + id: '77', + cmd: '/media/77', + info: { name: 'A later title' }, + }); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + fixture.detectChanges(); + + fixture.componentInstance.onVodBack(); + + expect(locationMock.back).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 1d34094a9..98fa25f03 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -7,11 +7,13 @@ import { input, signal, } from '@angular/core'; +import { Location } from '@angular/common'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { - getStalkerReturnToState, + consumeStalkerReturnMarker, + resolveStalkerBackNavigation, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, @@ -71,6 +73,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { PlaybackPositionRuntimeBridgeService ); private readonly router = inject(Router); + private readonly location = inject(Location); readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); @@ -224,12 +227,23 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } onVodBack(): void { - const returnTo = getStalkerReturnToState(window.history.state); + const back = resolveStalkerBackNavigation( + window.history.state, + this.selectedItem() + ); + // Closing the detail is unconditional: a `none` decision (no return + // target, or a marker left by an earlier handoff) still returns the + // user to the category list — it only suppresses the navigation. this.closeInlinePlayer(); this.catalog.clearSelectedItem(); - if (returnTo) { - void this.router.navigateByUrl(returnTo); + if (back.kind === 'history-back') { + // One-shot: retire the contract so a browser Forward onto this + // entry cannot replay it for a freshly opened title. + consumeStalkerReturnMarker(); + this.location.back(); + } else if (back.kind === 'navigate') { + void this.router.navigateByUrl(back.url); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index d5d0788df..dc50a34ca 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -586,9 +586,9 @@ describe('StalkerCollectionDetailComponent', () => { ); fixture.detectChanges(); - expect( - fixture.debugElement.injector.get(VIEW_IN_PORTAL_HANDOFF) - ).toBe(fixture.componentInstance); + expect(fixture.debugElement.injector.get(VIEW_IN_PORTAL_HANDOFF)).toBe( + fixture.componentInstance + ); expect(fixture.componentInstance.viewInPortalAvailable()).toBe(true); expect(fixture.componentInstance.viewInPortalPlaylistName()).toBe( 'Stalker Portal' @@ -619,6 +619,11 @@ describe('StalkerCollectionDetailComponent', () => { title: 'Series Nine', }), stalkerReturnTo: '/workspace/global-favorites', + // The portal detail's back affordance steps back through + // history so the collection keeps its tab and open detail. + // Bound to the handed-off item so a later title on the + // same history entry does not inherit it. + stalkerReturnByHistory: 'series-9', }, } ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts new file mode 100644 index 000000000..db201e0b5 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.back-navigation.spec.ts @@ -0,0 +1,212 @@ +import { Location } from '@angular/common'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + buildStalkerSelectedVodItem, + StalkerStore, +} from '@iptvnator/portal/stalker/data-access'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { EMPTY, of } from 'rxjs'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; + +describe('StalkerSeriesViewComponent back navigation', () => { + let fixture: ComponentFixture; + const routerMock = { navigateByUrl: jest.fn() }; + const locationMock = { back: jest.fn() }; + const clearSelectedItem = jest.fn(); + const originalHistoryState = window.history.state; + const selectedItem = signal | null>({ + id: '42', + }); + + beforeEach(async () => { + routerMock.navigateByUrl.mockReset(); + locationMock.back.mockReset(); + clearSelectedItem.mockReset(); + selectedItem.set({ id: '42' }); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType: signal('series'), + currentPlaylist: signal({ + _id: 'stalker|playlist', + title: 'Portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }), + getSerialSeasonsResource: () => [], + getVodSeriesSeasonsResource: () => [], + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes: jest.fn(), + resolveVodPlayback: jest.fn(), + fetchLinkToPlay: jest.fn(), + clearSelectedItem, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: jest + .fn() + .mockResolvedValue([]), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: locationMock }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: jest.fn(), + getSeasonEpisodes: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + window.history.replaceState(originalHistoryState, ''); + }); + + it('steps back through history for a collection handoff', () => { + // The originating collection keeps its tab, scope and open inline + // detail only on the previous history entry. + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(clearSelectedItem).toHaveBeenCalled(); + }); + + it('still re-navigates for a plain stalkerReturnTo handoff', () => { + window.history.replaceState( + { stalkerReturnTo: '/workspace/dashboard' }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(routerMock.navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard' + ); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('does not navigate when no return target is present', () => { + window.history.replaceState({}, ''); + + fixture.componentInstance.goBack(); + + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(locationMock.back).not.toHaveBeenCalled(); + }); + + it('retires the return contract so a forward-replay cannot fire it again', () => { + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + expect(window.history.state?.stalkerReturnByHistory).toBeUndefined(); + expect(window.history.state?.stalkerReturnTo).toBeUndefined(); + }); + + it('matches a selection whose id came from stream_id', () => { + selectedItem.set( + buildStalkerSelectedVodItem({ stream_id: '42' } as never) as never + ); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).toHaveBeenCalledTimes(1); + }); + + it('ignores a marker left over from an earlier handoff on this entry', () => { + // The return keys outlive the handoff, and a Stalker detail opens in + // place — so a later title on the same entry must just close. + selectedItem.set({ id: '77' }); + window.history.replaceState( + { + stalkerReturnTo: '/workspace/global-favorites', + stalkerReturnByHistory: '42', + }, + '' + ); + + fixture.componentInstance.goBack(); + + expect(locationMock.back).not.toHaveBeenCalled(); + expect(routerMock.navigateByUrl).not.toHaveBeenCalled(); + expect(clearSelectedItem).toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 55d480ac5..807d2be0c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -9,6 +9,7 @@ import { signal, untracked, } from '@angular/core'; +import { Location } from '@angular/common'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; @@ -39,7 +40,8 @@ import { PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, createLogger, - getStalkerReturnToState, + consumeStalkerReturnMarker, + resolveStalkerBackNavigation, } from '@iptvnator/portal/shared/util'; import { getVodSeriesSeasonKey, @@ -160,6 +162,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { }; private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly router = inject(Router); + private readonly location = inject(Location); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly playbackPositionBridge = inject( PlaybackPositionRuntimeBridgeService @@ -839,13 +842,24 @@ export class StalkerSeriesViewComponent implements OnDestroy { } goBack() { - const returnTo = getStalkerReturnToState(window.history.state); + const back = resolveStalkerBackNavigation( + window.history.state, + this.stalkerStore.selectedItem() + ); + // Closing the detail is unconditional: a `none` decision (no return + // target, or a marker left by an earlier handoff) still returns the + // user to the category list — it only suppresses the navigation. this.closeInlinePlayer(); this.backClicked.emit(); this.stalkerStore.clearSelectedItem(); - if (returnTo) { - void this.router.navigateByUrl(returnTo); + if (back.kind === 'history-back') { + // One-shot: retire the contract so a browser Forward onto this + // entry cannot replay it for a freshly opened title. + consumeStalkerReturnMarker(); + this.location.back(); + } else if (back.kind === 'navigate') { + void this.router.navigateByUrl(back.url); } }