fix(portals): reload Stalker categories only for a held destination

Review follow-ups (Greptile, Codex): resetCategories() reloaded the
category resource, and the route session calls it on a portal switch
before the destination is resolved and on teardown, so it asked the
portal being left, and a failed destination lookup could let that answer
repopulate the sidebar. resetCategories() now only clears; the session
calls the new reloadCategories() after installing the destination, and
only when a handoff had already put that playlist in the store (the
owner, and so the resource params, did not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-06 23:11:42 +02:00
1 parent 278245121c
commit c73a3e92a5
7 files changed
+84 -3

No files matched your search

+6 -2
View File
@@ -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:
@@ -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`
@@ -139,6 +139,7 @@ describe('StalkerStore API compatibility smoke', () => {
'clearSelectedItem',
'setCategories',
'resetCategories',
'reloadCategories',
'setItvChannels',
'setSearchPhrase',
'fetchVodSeriesEpisodes',
@@ -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)
@@ -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[]) {
@@ -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();
@@ -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) {