fix(portals): invalidate the session while the movie identity is empty

The staleness guard added in 4db3a2fd bumped the session only inside `load()`,
which leaves a window the guard does not cover: route navigation empties the
movie identity first, and `load()` for the replacement runs only once a title
is knowable again. A resolution completing in that interval still carried a
session number that matched, so it passed the check and started the previous
movie's source over the page the user was navigating to.

The binding effect now bumps the session as soon as the identity goes null, so
anything already in flight is invalidated at the moment the old movie stops
being the one on screen rather than when the next one finishes loading.

`lastMovieKey` is deliberately left alone: returning to the same movie should
not re-run discovery, and the controller's state is still correct — only the
in-flight operations needed invalidating.

Regression test added and mutation-checked: removing the bump fails exactly
that test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 11:21:21 +02:00
1 parent a715e05a09
commit 1936b45d0f
2 files changed
+27

No files matched your search

@@ -86,6 +86,27 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
);
});
it('drops a switch once the movie identity goes null mid-navigation', async () => {
await loadMovie([ALT_TWO]);
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
resolver.resolve.mockReturnValueOnce(slow.promise);
const pending = service.play(ALT_TWO.id);
// Route navigation empties the identity BEFORE the next movie's load()
// runs. Without invalidating the session here, the guard would still
// pass and the old movie's source would start over the new page.
movie.set(null);
TestBed.tick();
slow.resolve(resolvedFor(ALT_TWO, 0));
await expect(pending).resolves.toBe(false);
expect(startPlayback).not.toHaveBeenCalled();
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
});
it('drops a slower switch that a newer selection already superseded', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
@@ -146,6 +146,12 @@ export class VodMultiSourceHostService {
effect(() => {
const movie = bindings.movie();
if (!movie) {
// Navigating away empties the identity before the next movie's
// `load()` runs. Bumping the session here — not only in
// `load()` — closes the window in which a resolution still in
// flight for the PREVIOUS movie would pass the staleness guard
// and start its playback over the page the user is leaving.
this.discoveryToken++;
return;
}