From f80a899212cf2f10b79aefb33118d36d2c2b072c Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 1 Oct 2026 14:27:45 +0200 Subject: [PATCH] fix(epg): close a guide that never rendered rows without an EmptyError whenRowsRendered used first(predicate), which errors when the source completes without a match. The CDK completes renderedRangeStream on destroy, so closing a guide opened on an empty scope raised an unhandled EmptyError. Use filter + take(1) instead. Co-Authored-By: Claude Opus 5.5 --- .../epg-guide-viewport.controller.spec.ts | 28 ++++++++++++++++++- .../epg-guide-viewport.controller.ts | 8 ++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts index 2a3281eaa..5bbfe867b 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts @@ -1,7 +1,7 @@ import { ListRange } from '@angular/cdk/collections'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { DestroyRef } from '@angular/core'; -import { Subject } from 'rxjs'; +import { config, Subject } from 'rxjs'; import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util'; import { EpgGuideChannel } from './epg-guide-source'; @@ -220,6 +220,32 @@ describe('EpgGuideViewportController', () => { expect(callback).toHaveBeenCalledTimes(1); }); + it('closes cleanly when the viewport completes without ever rendering rows', async () => { + const test = harness(); + const callback = jest.fn(); + const onUnhandledError = jest.fn(); + const previous = config.onUnhandledError; + config.onUnhandledError = onUnhandledError; + try { + test.controller.whenRowsRendered( + test.viewport, + test.destroyRef, + callback + ); + // An empty scope: the CDK only ever reports an empty range, then + // completes the stream when the guide closes. + test.renderedRange$.next({ start: 0, end: 0 }); + test.renderedRange$.complete(); + // RxJS reports unhandled errors from a timeout. + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + config.onUnhandledError = previous; + } + + expect(onUnhandledError).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + it('drops the initial jump when the host is destroyed first', () => { const test = harness(); const callback = jest.fn(); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts index 3689661f9..187131b6b 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts @@ -2,7 +2,7 @@ import { ListRange } from '@angular/cdk/collections'; import { DestroyRef } from '@angular/core'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { first } from 'rxjs'; +import { filter, take } from 'rxjs'; import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; import { EpgGuideFocus } from './epg-guide-keyboard.controller'; import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util'; @@ -115,7 +115,11 @@ export class EpgGuideViewportController { ): void { viewport.renderedRangeStream .pipe( - first((range) => range.end > range.start), + // Not `first(predicate)`: the CDK completes the stream on + // destroy, and a guide closed without ever having rows would + // then raise an `EmptyError`. + filter((range) => range.end > range.start), + take(1), takeUntilDestroyed(destroyRef) ) .subscribe(() => callback());