diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index f63c4926c..b0314ec78 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -397,8 +397,12 @@ Category arrays belong to `categoryPlaylistKey`, not just the content type. Every category reader checks that owner; a portal switch clears all four section caches before loading the destination. Aborted or foreign-portal responses (including errors and radio fallbacks) cannot write into the active -cache. Explicit category resets reload even when the playlist object remains -unchanged. This contract applies to collection details as well as routed +cache. `resetCategories()` only clears: the route session calls it on a +portal switch before the destination is resolved and on teardown, where a +request would go to the portal being left. When a handoff had already put +the destination in the store, the owner does not change and the resource +would not reload by itself, so the session calls `reloadCategories()` once +it has installed that playlist. This contract applies to collection details as well as routed catalogs, because both use the root Stalker store. Failure-handling rule: diff --git a/docs/architecture/stalker-store-api-baseline.md b/docs/architecture/stalker-store-api-baseline.md index 1b29d177f..e2041a2f4 100644 --- a/docs/architecture/stalker-store-api-baseline.md +++ b/docs/architecture/stalker-store-api-baseline.md @@ -93,6 +93,9 @@ During refactor: - `clearSelectedItem(): void` - `setCategories(type: 'vod' | 'series' | 'itv' | 'radio', categories: StalkerCategoryItem[]): void` - `resetCategories(): void` + Clears only; it starts no request (called on portal switches and route teardown). +- `reloadCategories(): void` + Refetches the current portal's categories after a reset that keeps the owner. - `setItvChannels(channels: StalkerItvChannel[]): void` - `setRadioChannels(channels: StalkerItvChannel[]): void` - `setSearchPhrase(phrase: string): void` diff --git a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts index f4868a7e8..2dd134352 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker.store.compat.spec.ts @@ -139,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => { 'clearSelectedItem', 'setCategories', 'resetCategories', + 'reloadCategories', 'setItvChannels', 'setSearchPhrase', 'fetchVodSeriesEpisodes', diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts index d3e46da43..ac7f7872b 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts @@ -97,7 +97,21 @@ describe('Stalker category ownership', () => { void store.isCategoryResourceLoading(); }); - it('reloads categories when the active portal cache is explicitly reset', async () => { + it('sends no request on a reset alone', async () => { + request.mockResolvedValueOnce({ + js: [{ id: '1', title: 'Before reset' }], + }); + store.selectPlaylist('a'); + await settle(); + request.mockClear(); + + store.resetCategories(); + await settle(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('reloads categories when the active portal cache is explicitly reloaded after a reset', async () => { request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Before reset' }], }); @@ -107,6 +121,7 @@ describe('Stalker category ownership', () => { js: [{ id: '1', title: 'After reset' }], }); store.resetCategories(); + store.reloadCategories(); await settle(); expect( store.getCategoryResource().map((c) => c.category_name) diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index e07cb4000..e1f013eca 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -1214,6 +1214,8 @@ export function withStalkerContent() { ...buildCategoryPatch(type, categories), }); }, + // Clears only: called on portal switches and route teardown, + // where a reload would ask the portal being left. resetCategories() { patchState(store, { vodCategories: [], @@ -1222,6 +1224,13 @@ export function withStalkerContent() { radioCategories: [], categoryError: null, }); + }, + /** + * Refetches the current portal's categories. For a reset that + * keeps the owner: the resource's params do not change then, + * so it would not load on its own. + */ + reloadCategories() { storeContext.categoryResource.reload(); }, setItvChannels(channels: StalkerItvChannel[]) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts index c740d0813..8f2fa89a3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.spec.ts @@ -71,6 +71,7 @@ describe('StalkerWorkspaceRouteSession', () => { const stalkerStore = { currentPlaylist, resetCategories: jest.fn(), + reloadCategories: jest.fn(), setSelectedCategory: jest.fn(), clearSelectedItem: jest.fn(), setCurrentPlaylist: jest.fn( @@ -117,6 +118,7 @@ describe('StalkerWorkspaceRouteSession', () => { })); stalkerStore.resetCategories.mockClear(); + stalkerStore.reloadCategories.mockClear(); stalkerStore.setSelectedCategory.mockClear(); stalkerStore.clearSelectedItem.mockClear(); stalkerStore.setCurrentPlaylist.mockClear(); @@ -169,6 +171,46 @@ describe('StalkerWorkspaceRouteSession', () => { ); }); + // A switch must not ask the portal being left for categories: the new + // owner's params make the resource load on their own. + it('does not reload categories when the store switches portals', async () => { + currentPlaylist.set(OTHER_PLAYLIST); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.resetCategories).toHaveBeenCalled(); + expect(currentPlaylist()?._id).toBe(PLAYLIST_ID); + expect(stalkerStore.reloadCategories).not.toHaveBeenCalled(); + }); + + // A handoff installed the destination in the store first: the reset + // keeps the owner, so only an explicit reload brings the categories back. + it('reloads the cleared categories once a held destination is installed', async () => { + currentPlaylist.set(ACTIVE_PLAYLIST); + + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + + expect(stalkerStore.reloadCategories).toHaveBeenCalledTimes(1); + expect( + stalkerStore.reloadCategories.mock.invocationCallOrder[0] + ).toBeGreaterThan( + stalkerStore.setCurrentPlaylist.mock.invocationCallOrder[0] + ); + }); + + it('does not reload categories when the route is torn down', async () => { + TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + stalkerStore.reloadCategories.mockClear(); + + TestBed.resetTestingModule(); + + expect(stalkerStore.resetCategories).toHaveBeenCalled(); + expect(stalkerStore.reloadCategories).not.toHaveBeenCalled(); + }); + it('reconciles a revisited route when collection detail changed the shared store', async () => { const session = TestBed.inject(StalkerWorkspaceRouteSession); await flushEffects(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts index 7b01599c9..fe5ba7092 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-route-session.service.ts @@ -140,6 +140,10 @@ export class StalkerWorkspaceRouteSession { this.currentPlaylistId !== playlistId || this.stalkerStore.currentPlaylist()?._id !== playlistId ) { + // A handoff can install the destination in the store before this + // session does; the categories it held are cleared all the same. + const storeHeldDestination = + this.stalkerStore.currentPlaylist()?._id === playlistId; this.stalkerStore.resetCategories(); this.stalkerStore.setSelectedCategory(null); this.stalkerStore.clearSelectedItem(); @@ -154,6 +158,9 @@ export class StalkerWorkspaceRouteSession { } await this.stalkerStore.setCurrentPlaylist(playlist); this.currentPlaylistId = playlistId; + // The owner did not change, so the category resource keeps its + // params and would not reload the cleared categories by itself. + if (storeHeldDestination) this.stalkerStore.reloadCategories(); } if (generation !== this.syncGeneration || this.destroyRef.destroyed) {