fix(web): lazy load route feature boundaries

Entire-Checkpoint: c2cea9c530e6
This commit is contained in:
4gray committed 2026-05-01 20:38:14 +02:00
1 parent c51d8161ea
commit 6ef9e8c4b0
8 files changed
+239 -77

No files matched your search

+49 -13
View File
@@ -2,6 +2,7 @@ describe('app routes', () => {
let workspaceChildren: Array<{
canActivate?: unknown[];
data?: Record<string, unknown>;
loadChildren?: unknown;
loadComponent?: unknown;
path?: string;
redirectTo?: unknown;
@@ -9,25 +10,61 @@ describe('app routes', () => {
beforeAll(async () => {
jest.resetModules();
jest.unstable_mockModule('@iptvnator/playlist/m3u/feature-player', () => ({
M3uCollectionRouteComponent: class {},
VideoPlayerComponent: class {},
provideM3uWorkspaceRouteSession: () => [],
}));
jest.unstable_mockModule(
'@iptvnator/playlist/m3u/feature-player',
() => ({
createM3uWorkspaceRoutes: () => [
{
path: '',
pathMatch: 'full',
redirectTo: 'all',
},
{
path: 'favorites',
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'recent',
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: ':view',
},
],
})
);
const { routes } = await import('./app.routes');
const workspaceRoute = routes.find((route) => route.path === 'workspace');
const workspaceRoute = routes.find(
(route) => route.path === 'workspace'
);
workspaceChildren = workspaceRoute?.children ?? [];
});
it('routes M3U favorites and recent pages through the shared collection wrapper', async () => {
const favoritesRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id/favorites'
it('lazy-loads M3U workspace routes through the feature boundary', async () => {
const playlistRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id'
);
const recentRoute = workspaceChildren.find(
(route) => route.path === 'playlists/:id/recent'
const loadChildren = playlistRoute?.loadChildren as
| (() => Promise<typeof workspaceChildren>)
| undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
(route) => route.path === 'favorites'
);
const recentRoute = m3uRoutes.find((route) => route.path === 'recent');
const playerRoute = m3uRoutes.find((route) => route.path === ':view');
expect(defaultRoute?.redirectTo).toBe('all');
expect(favoritesRoute?.data).toEqual({
mode: 'favorites',
portalType: 'm3u',
@@ -39,8 +76,7 @@ describe('app routes', () => {
defaultScope: 'playlist',
});
expect(typeof favoritesRoute?.loadComponent).toBe('function');
expect(typeof recentRoute?.loadComponent).toBe('function');
expect(playerRoute).toBeDefined();
});
it('adds a shared /workspace/global-recent route', async () => {
+3 -36
View File
@@ -1,6 +1,5 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { provideM3uWorkspaceRouteSession } from '@iptvnator/playlist/m3u/feature-player';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
const workspaceEntryRedirect = async () =>
@@ -54,41 +53,9 @@ export const routes: Routes = [
},
{
path: 'playlists/:id',
pathMatch: 'full',
redirectTo: 'playlists/:id/all',
},
{
path: 'playlists/:id/favorites',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.M3uCollectionRouteComponent
),
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'playlists/:id/recent',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.M3uCollectionRouteComponent
),
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'playlists/:id/:view',
providers: provideM3uWorkspaceRouteSession(),
loadComponent: () =>
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.VideoPlayerComponent
loadChildren: () =>
import('@iptvnator/playlist/m3u/feature-player').then((m) =>
m.createM3uWorkspaceRoutes()
),
},
{
@@ -1,9 +1,16 @@
import {
ChangeDetectionStrategy,
Component,
EnvironmentInjector,
OnDestroy,
Type,
ViewContainerRef,
effect,
computed,
inject,
input,
output,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -14,14 +21,24 @@ import {
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
} from '@iptvnator/portal/shared/ui';
import { CollectionScope, PortalProvider } from '@iptvnator/portal/shared/util';
import { StalkerCollectionDetailComponent } from '@iptvnator/portal/stalker/feature';
import { XtreamCollectionDetailComponent } from '@iptvnator/portal/xtream/feature';
import {
CollectionScope,
PortalProvider,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
type UnifiedPortalType = 'm3u' | 'xtream' | 'stalker';
type CollectionMode = 'favorites' | 'recent';
interface ComponentOutputRef {
subscribe(callback: () => void): { unsubscribe(): void };
}
interface CollectionDetailInstance {
closeRequested?: ComponentOutputRef;
}
const EMPTY_STATE_BY_MODE: Record<
CollectionMode,
{ icon: string; titleKey: string; descriptionKey: string }
@@ -44,14 +61,100 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
return 'stalker';
}
@Component({
selector: 'app-global-collection-detail-host',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
private readonly environmentInjector = inject(EnvironmentInjector);
private closeSubscription: { unsubscribe(): void } | null = null;
private renderRequestId = 0;
constructor() {
effect(() => {
const item = this.item();
untracked(() => {
void this.renderDetail(item);
});
});
}
ngOnDestroy(): void {
this.renderRequestId++;
this.clearDetail();
}
private async renderDetail(
item: UnifiedCollectionItem | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
if (!item) {
return;
}
const componentType = await this.loadDetailComponent(item.sourceType);
if (requestId !== this.renderRequestId || !componentType) {
return;
}
const componentRef = this.viewContainer.createComponent(componentType, {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
this.subscribeToClose(componentRef.instance);
}
private async loadDetailComponent(
sourceType: UnifiedCollectionItem['sourceType']
): Promise<Type<unknown> | null> {
if (sourceType === 'xtream') {
const component = await import('@iptvnator/portal/xtream/feature');
return component.XtreamCollectionDetailComponent;
}
if (sourceType === 'stalker') {
const component = await import('@iptvnator/portal/stalker/feature');
return component.StalkerCollectionDetailComponent;
}
return null;
}
private subscribeToClose(instance: unknown): void {
const closeRequested = (instance as CollectionDetailInstance)
.closeRequested;
if (!closeRequested) {
return;
}
this.closeSubscription = closeRequested.subscribe(() => {
this.closeRequested.emit();
});
}
private clearDetail(): void {
this.closeSubscription?.unsubscribe();
this.closeSubscription = null;
this.viewContainer.clear();
}
}
@Component({
selector: 'app-global-collection-route',
imports: [
EmptyStateComponent,
StalkerCollectionDetailComponent,
GlobalCollectionDetailHostComponent,
UnifiedCollectionDetailDirective,
UnifiedCollectionPageComponent,
XtreamCollectionDetailComponent,
],
template: `
@if (hasNoPlaylists()) {
@@ -74,9 +177,9 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
>
<ng-template unifiedCollectionDetail let-item let-close="close">
@if (item.sourceType === 'xtream') {
<app-xtream-collection-detail [item]="item" />
<app-global-collection-detail-host [item]="item" />
} @else if (item.sourceType === 'stalker') {
<app-stalker-collection-detail
<app-global-collection-detail-host
[item]="item"
(closeRequested)="close()"
/>
@@ -96,7 +199,9 @@ export class GlobalCollectionRouteComponent {
readonly mode = input<CollectionMode>('favorites');
readonly defaultScope = input<CollectionScope | undefined>(undefined);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly hasNoPlaylists = computed(() => this.playlists().length === 0);
readonly emptyState = computed(() => EMPTY_STATE_BY_MODE[this.mode()]);
@@ -108,8 +213,11 @@ export class GlobalCollectionRouteComponent {
const provider = this.playlistContext.activeProvider();
return provider ? providerToPortalType(provider) : null;
});
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(() =>
this.activePlaylistId() ? 'playlist' : (this.defaultScope() ?? 'all')
readonly effectiveDefaultScope = computed<CollectionScope | undefined>(
() =>
this.activePlaylistId()
? 'playlist'
: (this.defaultScope() ?? 'all')
);
addPlaylist(): void {
@@ -117,6 +117,11 @@ interface SettingsSectionScrollDirectiveTestApi {
getScrollRoot(): HTMLElement | null;
}
interface SettingsComponentPrivateTestApi {
matDialog: MatDialog;
waitForUiFeedbackFrame(): Promise<void>;
}
describe('SettingsComponent', () => {
let component: SettingsComponent;
let fixture: ComponentFixture<SettingsComponent>;
@@ -145,6 +150,11 @@ describe('SettingsComponent', () => {
...overrides,
});
const createDialogRef = (result: boolean): ReturnType<MatDialog['open']> =>
({
afterClosed: () => of(result),
}) as unknown as ReturnType<MatDialog['open']>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
providers: [
@@ -276,6 +286,12 @@ describe('SettingsComponent', () => {
fixture.detectChanges();
}
function privateApi(
settingsComponent: SettingsComponent
): SettingsComponentPrivateTestApi {
return settingsComponent as unknown as SettingsComponentPrivateTestApi;
}
it('should create and init component', () => {
expect(component).toBeTruthy();
});
@@ -557,10 +573,8 @@ describe('SettingsComponent', () => {
]);
const openSpy = jest
.spyOn((component as any).matDialog, 'open')
.mockReturnValue({
afterClosed: () => of(false),
} as any);
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.removeAll();
@@ -596,9 +610,9 @@ describe('SettingsComponent', () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
jest.spyOn((component as any).matDialog, 'open').mockReturnValue({
afterClosed: () => of(true),
} as any);
jest.spyOn(privateApi(component).matDialog, 'open').mockReturnValue(
createDialogRef(true)
);
jest.spyOn(translate, 'instant').mockImplementation(
(key: string, params?: Record<string, number>) => {
if (key === 'SETTINGS.REMOVE_ALL_PROGRESS') {
@@ -608,7 +622,7 @@ describe('SettingsComponent', () => {
}
);
jest.spyOn(
component as any,
privateApi(component),
'waitForUiFeedbackFrame'
).mockResolvedValue(undefined);
@@ -688,13 +702,12 @@ describe('SettingsComponent', () => {
mockStore.refreshState();
browserFixture.detectChanges();
jest.spyOn((browserComponent as any).matDialog, 'open').mockReturnValue(
{
afterClosed: () => of(true),
} as any
);
jest.spyOn(
browserComponent as any,
privateApi(browserComponent).matDialog,
'open'
).mockReturnValue(createDialogRef(true));
jest.spyOn(
privateApi(browserComponent),
'waitForUiFeedbackFrame'
).mockResolvedValue(undefined);
(databaseService.deleteAllPlaylists as jest.Mock).mockClear();
@@ -1,3 +1,4 @@
export * from './lib/m3u-collection-route/m3u-collection-route.component';
export * from './lib/m3u-workspace.routes';
export * from './lib/m3u-workspace-route-session.service';
export * from './lib/video-player/video-player.component';
@@ -0,0 +1,39 @@
import { Route } from '@angular/router';
import { M3uCollectionRouteComponent } from './m3u-collection-route/m3u-collection-route.component';
import { provideM3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
import { VideoPlayerComponent } from './video-player/video-player.component';
export function createM3uWorkspaceRoutes(): Route[] {
return [
{
path: '',
pathMatch: 'full',
redirectTo: 'all',
},
{
path: 'favorites',
providers: provideM3uWorkspaceRouteSession(),
component: M3uCollectionRouteComponent,
data: {
mode: 'favorites',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: 'recent',
providers: provideM3uWorkspaceRouteSession(),
component: M3uCollectionRouteComponent,
data: {
mode: 'recent',
portalType: 'm3u',
defaultScope: 'playlist',
},
},
{
path: ':view',
providers: provideM3uWorkspaceRouteSession(),
component: VideoPlayerComponent,
},
];
}
@@ -475,7 +475,7 @@ describe('WorkspaceShellFacade', () => {
},
{
icon: 'favorite',
tooltip: 'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES',
tooltip: 'HOME.PLAYLISTS.GLOBAL_FAVORITES',
path: ['/workspace/global-favorites'],
exact: true,
},
@@ -189,9 +189,7 @@ export class WorkspaceShellFacade {
links.push({
icon: 'favorite',
tooltip: this.translateText(
'WORKSPACE.SHELL.RAIL_GLOBAL_FAVORITES'
),
tooltip: this.translateText('HOME.PLAYLISTS.GLOBAL_FAVORITES'),
path: ['/workspace/global-favorites'],
exact: true,
});