diff --git a/.changes/electron-window-shown-on-load.md b/.changes/electron-window-shown-on-load.md new file mode 100644 index 000000000..49dc2ef01 --- /dev/null +++ b/.changes/electron-window-shown-on-load.md @@ -0,0 +1,8 @@ +--- +type: perf +area: electron +--- + +On Linux the desktop app's window no longer sometimes appears about a second +late at launch: it now opens as soon as the app has loaded, showing the +loading screen until the dashboard is ready. diff --git a/.changes/portals-playlist-handoff.md b/.changes/portals-playlist-handoff.md new file mode 100644 index 000000000..b959c39f9 --- /dev/null +++ b/.changes/portals-playlist-handoff.md @@ -0,0 +1,6 @@ +--- +type: fix +area: portals +--- + +Opening a title in its portal from favorites, history or dashboard keeps the correct playlist and categories. Switching portals no longer lets an older loading request replace the current selection, and Stalker details wait for their destination portal before opening. diff --git a/.changes/workspace-back-parent-fallback.md b/.changes/workspace-back-parent-fallback.md new file mode 100644 index 000000000..b6536e7f9 --- /dev/null +++ b/.changes/workspace-back-parent-fallback.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +Back on Settings, search, Discover and actor pages now works when that page is the first one you opened, for example after a reload or from a link: it takes you to the dashboard or to the playlist's movies or series instead of doing nothing or leaving the app. diff --git a/.changes/workspace-header-traffic-light-zoom.md b/.changes/workspace-header-traffic-light-zoom.md new file mode 100644 index 000000000..4263f10ac --- /dev/null +++ b/.changes/workspace-header-traffic-light-zoom.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +On macOS, zooming the app out no longer slides the Back button and the playlist switcher under the window's close, minimize and zoom buttons; the top bar also stays tall enough that the buttons never overlap the page below. diff --git a/.github/actions/performance-journeys/action.yml b/.github/actions/performance-journeys/action.yml index 5b9ead263..5a5246eec 100644 --- a/.github/actions/performance-journeys/action.yml +++ b/.github/actions/performance-journeys/action.yml @@ -72,5 +72,11 @@ runs: (($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"), "", ([($j.iterations // [])[] | select(.warmup | not) | .evidence.ipcSerialDepth // empty][0] // empty | - "Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", "")) + "Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", ""), + ([($j.iterations // [])[] | select(.warmup | not) | .evidence.perKeystroke // empty][0] // empty | + "Per keystroke (first measured iteration):", "", + "| Key | Query calls | Bridge calls | SQL statements | DOM mutations | CD ticks |", + "| --- | ---: | ---: | ---: | ---: | ---: |", + (.[] | "| `\(.key)` | \(.queryCalls) | \(.ipcCalls) | \(.sqlStatements) | \(.domMutations) | \(.cdTicks) |"), + "")) ' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index e31a370fc..3036c9153 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -416,7 +416,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 967d2f03a..3a8b8cf4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: fetch-depth: 0 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -140,7 +140,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -296,8 +296,8 @@ jobs: if: needs.performance-journeys-scope.outputs.run == 'true' runs-on: ubuntu-latest # The electron-performance build is the bulk of the time; each journey - # (launch, open-source, playback) is six fresh Electron processes plus - # one seeding run. + # (launch, open-source, playback, search) is six fresh Electron + # processes plus one seeding run. timeout-minutes: 30 # Warn-only for the first two weeks of plan item B3: a failure is # visible on the run but does not fail the workflow. @@ -310,7 +310,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -324,8 +324,36 @@ jobs: # Electron dependency check, the xvfb run, the summary lookup and # the job-summary report; shared with performance-ratchet.yml. - name: Run the performance journeys + id: journeys uses: ./.github/actions/performance-journeys + # Only the counters identical in every measured iteration of + # recent master runs; their entries say whether a counter is + # validated against wall-clock or a guard only (see Ratchet in + # docs/architecture/performance-journeys.md). Here and not in the + # composite action, so the weekly tightening still measures a run + # that would fail it. tools/performance tests keep this list equal + # to the journey entries of journey-baselines.json. It also runs + # when a later step of the action (the job-summary report) failed + # after the summary was written, so the counters are still checked. + - name: Check the journey counters against the baselines + if: ${{ !cancelled() && steps.journeys.outputs.summary != '' }} + env: + SUMMARY: ${{ steps.journeys.outputs.summary }} + run: >- + node tools/performance/check-journey-ratchet.mjs + --summary "$SUMMARY" + --only launch/renderer.ipcCallsToFirstCard + --only launch/renderer.domMutationsToFirstCard + --only launch/main.modulesRegisteredBeforeWindow + --only launch/renderer.layoutShiftScore + --only launch/renderer.layoutShiftScoreSettled + --only open-source/main.mockHttpRequestsToSettled + --only open-source/renderer.ipcCallsToFirstPage + --only open-source/renderer.layoutShiftScore + --only playback/renderer.httpRequestsToPlaying + --only playback/renderer.layoutShiftScore + - name: Upload journey summaries if: always() uses: actions/upload-artifact@v7 @@ -349,7 +377,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 74e397a8f..93a615c45 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -54,7 +54,7 @@ jobs: # commit checked out above (the modern default); JavaScript is # interpreted, so no build step is needed before analysis. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.7 + uses: github/codeql-action/init@v4.38.2 with: languages: ${{ matrix.language }} # Excludes the localhost dev/E2E mock servers from analysis; see the @@ -66,4 +66,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.7 + uses: github/codeql-action/analyze@v4.38.2 diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index a8cf6f076..f6e7dec04 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -27,7 +27,7 @@ jobs: uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index d3fb6874b..42bbd8f98 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -67,7 +67,7 @@ jobs: - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -201,7 +201,7 @@ jobs: - uses: actions/checkout@v7 - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/.github/workflows/performance-ratchet.yml b/.github/workflows/performance-ratchet.yml index 39e3a0c4d..857240120 100644 --- a/.github/workflows/performance-ratchet.yml +++ b/.github/workflows/performance-ratchet.yml @@ -43,7 +43,7 @@ jobs: persist-credentials: false - name: Install pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 - name: Setup Node.js uses: actions/setup-node@v7 @@ -240,7 +240,7 @@ jobs: git diff "$HEAD_SHA" HEAD -- "$baselines" echo '```' echo - echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet job passes on this PR before merging." + echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet and Performance journeys jobs pass on this PR before merging." } > "$BODY" gh api -X PATCH "repos/$REPOSITORY/pulls/$pr" -F "body=@$BODY" --silent echo "Pull request: ${GITHUB_SERVER_URL}/$REPOSITORY/pull/$pr" diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index 7be2f2e85..f45655f48 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -13,6 +13,10 @@ import { test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + expectRendererReloadedOnRoute, + reloadFromMainProcess, +} from './renderer-reload.support'; // --------------------------------------------------------------------------- // A detail page's Back lives in the workspace header's leading slot, not in @@ -29,6 +33,10 @@ import { // header's history fallback while an in-app previous page exists; on a // phone it yields to the drawer toggle, and with nowhere to go the slot is // empty rather than a disabled arrow. +// +// A page with a parent route (settings here) that opened the session, as +// after a reload, has no in-app entry for history Back: its Back leads to +// the parent instead and replaces the page's entry. // --------------------------------------------------------------------------- const widths = [1280, 780, 375]; @@ -346,4 +354,33 @@ test.describe('Portal detail header Back', () => { await closeElectronApp(app); } }); + + test('@electron @settings settings Back after a reload leads to the dashboard', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await page.waitForURL(/\/workspace\/dashboard$/); + await openSettings(page); + + // The reloaded document re-boots on the settings route; the + // dashboard entry before it belongs to the old document. + await reloadFromMainProcess(app); + await expectRendererReloadedOnRoute( + page, + /\/workspace\/settings\/general$/ + ); + await expect(page.getByTestId('settings-container')).toBeVisible(); + + await headerBack(page).click(); + await expect(page).toHaveURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: nothing in this + // document precedes it, so the header offers no Back. + await expect(headerBack(page)).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 3a0bf7dfd..b7b466a49 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -17,6 +17,8 @@ import { expect, importM3uPlaylistFromNativeDialog, launchElectronApp, + goToDashboard, + openGlobalRecent, openWorkspaceSection, openPlaylistFavorites, openSources, @@ -41,8 +43,158 @@ import { getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; +import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; test.describe('Electron Favorites', () => { + for (const origin of ['favorites', 'recent', 'dashboard'] as const) { + test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['stalker']); + const vod = await fetchStalkerCategoryFixture(request, 'vod'); + const [title] = pickDistinctTitles(vod.items, getStalkerTitle); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + await routePlayableStreams(page); + try { + await addStalkerPortal(page, { name: 'Portal A' }); + await waitForStalkerCatalog(page); + await clickCategoryById(page, vod.categoryId); + const portalPath = new URL(page.url()).pathname.replace( + /\/vod(?:\/[^/]+)?$/, + `/vod/${vod.categoryId}` + ); + const categoriesA = await readVisibleSidebarCategoryNames(page); + await clickGridListCardByTitle(page, title); + await addCurrentDetailToFavorites(page); + if (origin !== 'favorites') { + await startAndConfirmPlayback(page, () => + page.locator('button.play-btn').first().click() + ); + } + await goBackFromDetail(page); + if (origin !== 'favorites') { + // Represent an already saved viewing position. The short + // media fixture ends before the player's 15 s save interval. + const playlistId = /\/stalker\/([^/]+)/.exec( + portalPath + )?.[1]; + const contentId = Number( + vod.items.find( + (item) => getStalkerTitle(item) === title + )?.id + ); + expect(playlistId).toBeTruthy(); + await page.evaluate( + async ({ playlistId, contentId }) => { + await window.electron.dbSavePlaybackPosition( + playlistId!, + { + contentXtreamId: contentId, + contentType: 'vod', + playlistType: 'stalker', + positionSeconds: 2, + durationSeconds: 6, + } + ); + }, + { playlistId, contentId } + ); + } + + await openSources(page); + await addStalkerPortal(page, { + name: 'Portal B', + macAddress: '00:1A:79:00:00:03', + }); + await waitForStalkerCatalog(page); + const categoriesB = await readVisibleSidebarCategoryNames(page); + expect(categoriesB).not.toEqual(categoriesA); + + if (origin === 'dashboard') { + await goToDashboard(page); + await page + .locator( + '[data-test-id="dashboard-continue-watching-rail-card"]' + ) + .filter({ hasText: title }) + .first() + .click(); + } else { + if (origin === 'favorites') + await openWorkspaceSection(page, 'Global favorites'); + else await openGlobalRecent(page); + await switchUnifiedCollectionScope(page, 'All playlists'); + await contentCardByTitle(page, title).first().click(); + } + await expect(page.locator('app-content-hero')).toContainText( + title + ); + if (origin !== 'favorites') { + await startAndConfirmPlayback( + page, + () => + page + .getByRole('button', { name: /^Continue/ }) + .first() + .click(), + 0.5 + ); + await page + .getByRole('button', { + name: 'Close player', + exact: true, + }) + .click(); + } + await page + .getByRole('button', { + name: 'View in portal', + exact: true, + }) + .click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toBe(portalPath); + await expect(page.locator('app-content-hero')).toContainText( + title + ); + await expect + .poll(() => readVisibleSidebarCategoryNames(page)) + .toEqual(categoriesA); + + await goBackFromDetail(page); + await expectPathname( + page, + origin === 'favorites' + ? /\/workspace\/global-favorites$/ + : /\/workspace\/global-recent$/ + ); + await expect(page.locator('app-content-hero')).toContainText( + title + ); + for (const [name, expected] of [ + ['Portal B', categoriesB], + ['Portal A', categoriesA], + ] as const) { + await openSources(page); + await sourceRowByTitle(page, name).first().click(); + await waitForStalkerCatalog(page); + await expect + .poll(() => readVisibleSidebarCategoryNames(page)) + .toEqual(expected); + } + } finally { + await closeElectronApp(app); + } + }); + } + test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({ dataDir, }) => { diff --git a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts index 91387fc9d..61c0b2957 100644 --- a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts +++ b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts @@ -12,6 +12,8 @@ export interface JourneyRendererGateState { readonly gatedEpochMs: number | null; readonly gatedMethod: string | null; readonly passThroughLoads: number; + /** `did-finish-load` events kept from the app's listeners on about:blank. */ + readonly didFinishLoadHeldOnBlank: number; /** `ready-to-show` events dropped while the window was on about:blank. */ readonly readyToShowHeldOnBlank: number; readonly releasedEpochMs: number | null; diff --git a/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts b/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts index 1068f9f83..9b80ced07 100644 --- a/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts @@ -1,10 +1,14 @@ -import type { ElectronApplication, Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { configureLiveFormat } from '../xtream-live-format.fixture'; import { JOURNEY_CLICK_QUIET_MS, waitForJourneyClickQuiet, } from '../performance/journey-click-settle'; +import { + blockJourneyExternalArtwork, + readJourneyExternalArtworkCancelled, +} from '../performance/journey-external-artwork'; import { detachJourneyMainIpcCapture, installJourneyMainIpcCapture, @@ -47,14 +51,6 @@ export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY = '__iptvnatorJourneyPlaybackMainIpcCapture'; export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal'; const ERROR_PREFIX = 'playback-journey'; -const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork'; -/** - * The generated live catalog's channel and category logos point at - * picsum.photos. They are cancelled in the main process, so no request of - * the journey leaves the machine and a logo never loads, or fails, at a - * different moment on a runner with a different network. - */ -const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*']; /** Seeds J2's profile with the local-media portal and the HTML5 player. */ export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = { @@ -68,45 +64,6 @@ export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = { }, }; -async function blockExternalArtwork( - electronApp: ElectronApplication -): Promise { - await electronApp.evaluate( - ({ session }, input) => { - const target = globalThis as unknown as Record; - if (target[input.key] !== undefined) { - throw new Error('playback-journey-artwork-block-installed'); - } - const state = { cancelled: 0 }; - target[input.key] = state; - // The app registers no onBeforeRequest listener of its own - // (only onBeforeSendHeaders), so this replaces nothing. - session.defaultSession.webRequest.onBeforeRequest( - { urls: input.urls }, - (_details, callback) => { - state.cancelled += 1; - callback({ cancel: true }); - } - ); - }, - { key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS } - ); -} - -async function readCancelledExternalArtwork( - electronApp: ElectronApplication -): Promise { - return electronApp.evaluate( - (_electron, key) => - ( - (globalThis as unknown as Record)[key] as { - cancelled: number; - } - ).cancelled, - EXTERNAL_ARTWORK_STATE_KEY - ); -} - /** Dashboard card → live section → first category, as a user would. */ async function openLiveCategory(page: Page, timeoutMs: number): Promise { await page @@ -143,7 +100,7 @@ export async function measurePlaybackJourney( if (!startClick) { throw new Error('playback-journey-probe-without-start'); } - await blockExternalArtwork(electronApp); + await blockJourneyExternalArtwork(electronApp); await openLiveCategory(mainWindow, timeoutMs); const channel = mainWindow.locator(startClick.selector).first(); await channel.waitFor({ state: 'visible', timeout: timeoutMs }); @@ -199,7 +156,7 @@ export async function measurePlaybackJourney( ); return { externalArtworkCancelled: - await readCancelledExternalArtwork(electronApp), + await readJourneyExternalArtworkCancelled(electronApp), http: { afterPlaying: sinceSpawn.filter( (entry) => diff --git a/apps/electron-backend-e2e/src/journeys/search-journey-app.ts b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts new file mode 100644 index 000000000..7d76b54fe --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts @@ -0,0 +1,435 @@ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { + blockJourneyExternalArtwork, + readJourneyExternalArtworkCancelled, +} from '../performance/journey-external-artwork'; +import { + JOURNEY_MAIN_COUNTER, + JOURNEY_PERFORMANCE_COUNTERS_CHANNEL, +} from '../performance/journey-main-counters'; +import { + countJourneyMainIpcInFlight, + detachJourneyMainIpcCapture, + installJourneyMainIpcCapture, + JOURNEY_MAIN_IPC_STATE_KEY, + JOURNEY_RENDERER_API_TRACE_CHANNEL, + peekJourneyMainIpcCaptures, + readJourneyMainIpcCapture, +} from '../performance/journey-main-ipc-capture'; +import { waitForJourneyQuiet } from '../performance/journey-quiet-wait'; +import { + armSearchJourneyProbe, + createSearchJourneyProbeOptions, + readSearchJourneyPreStartMutations, + type SearchJourneyProbeOptions, + SEARCH_JOURNEY_INPUT_SELECTOR, + SEARCH_JOURNEY_ROUTE_PATH, + waitForSearchJourneyProbe, +} from '../performance/search-journey-probe'; +import { + SEARCH_JOURNEY_QUERY_METHOD, + type SearchJourneyActivitySample, + type SearchJourneyMeasurement, + type SearchJourneyQueryTraceEntry, + type SearchJourneySettle, +} from '../performance/search-journey-record'; +import { JOURNEY_RENDERER_GATE_KEY } from './journey-renderer-gate-client'; +import type { + LaunchJourneySeedOptions, + LaunchJourneySession, +} from './launch-journey-app'; + +/** + * J4 "Search": runs inside a process that J1 has just launched with the + * main-process counters on (SQL statements are counted). The test opens + * global search from the rail and focuses the header search box (not + * measured), lets the app settle, then types the query one key at a time + * at a fixed interval and measures until the results have settled. + * + * Main-process activity (bridge calls, SQL statements) is sampled before + * every keystroke, so the record can show what each key caused: with the + * shell's debounce, only the last interval should run a query. Contract: + * docs/architecture/performance-journeys.md. + */ +export const SEARCH_JOURNEY_MAIN_IPC_STATE_KEY = + '__iptvnatorJourneySearchMainIpcCapture'; +export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal'; +/** + * Six characters; on the mock's `large` account (12,000 items) the term + * matches 170 series titles, more than the first page of 100. + */ +export const SEARCH_JOURNEY_QUERY = 'system'; +/** Well below the shell's 350 ms input debounce, like steady typing. */ +export const SEARCH_JOURNEY_KEY_DELAY_MS = 100; +const QUIET_MS = 1_000; +const QUIET_POLL_MS = 100; +const QUIET_TIMEOUT_MS = 30_000; +const AFTER_SETTLED_WINDOW_MS = 500; +const ERROR_PREFIX = 'search-journey'; + +/** J1's M3U source plus the mock's existing 12,000-item `large` catalog. */ +export const SEARCH_JOURNEY_SEED: LaunchJourneySeedOptions = { + portal: { + name: SEARCH_JOURNEY_PORTAL_NAME, + password: 'large', + username: 'large', + }, +}; + +/** + * One synchronous pass in the main process: the capture's counts and the + * registered counters handler (which reads the registry synchronously), so + * no bridge call or SQL report can land between the two reads. + */ +async function sampleActivity( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const capture = target[input.captureKey] as + | { + callsBeforeSentinel: number; + callsByMethod: Record; + } + | undefined; + const gate = target[input.gateKey] as + | { invokeHandler?: (channel: string) => Promise } + | undefined; + if (!capture || typeof gate?.invokeHandler !== 'function') { + throw new Error('search-journey-sample-unavailable'); + } + const ipcCalls = capture.callsBeforeSentinel; + const queryCalls = capture.callsByMethod[input.queryMethod] ?? 0; + const pending = gate.invokeHandler(input.channel); + const snapshot = (await pending) as { + counters?: Record; + } | null; + const sqlStatements = snapshot?.counters?.[input.sqlCounter]; + if (typeof sqlStatements !== 'number') { + throw new Error('search-journey-sql-counter-missing'); + } + return { ipcCalls, queryCalls, sqlStatements }; + }, + { + captureKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY, + channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL, + gateKey: JOURNEY_RENDERER_GATE_KEY, + queryMethod: SEARCH_JOURNEY_QUERY_METHOD, + sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS, + } + ); +} + +/** + * Waits until DOM, bridge calls (started and in flight) and SQL statements + * have all been unchanged for `QUIET_MS`, so leftovers of the launch and of + * the navigation to global search are not attributed to the first key. + */ +async function waitForSearchQuiet( + electronApp: ElectronApplication, + page: Page, + probeStateKey: string +): Promise { + const { sample, waitedMs } = await waitForJourneyQuiet({ + inFlight: (activity) => activity.ipcInFlight, + pollMs: QUIET_POLL_MS, + quietMs: QUIET_MS, + sample: async () => { + const [launchCapture, journeyCapture] = + await peekJourneyMainIpcCaptures(electronApp, [ + JOURNEY_MAIN_IPC_STATE_KEY, + SEARCH_JOURNEY_MAIN_IPC_STATE_KEY, + ]); + if (launchCapture.unmatchedCompletions > 0) { + throw new Error(`${ERROR_PREFIX}-bridge-completions-unmatched`); + } + return { + domMutations: await readSearchJourneyPreStartMutations( + page, + probeStateKey + ), + ipcCalls: journeyCapture.callsBeforeStart, + ipcInFlight: countJourneyMainIpcInFlight(launchCapture), + sqlStatements: (await sampleActivity(electronApp)) + .sqlStatements, + }; + }, + timeoutError: (activity) => + new Error(`${ERROR_PREFIX}-not-quiet: ${JSON.stringify(activity)}`), + timeoutMs: QUIET_TIMEOUT_MS, + }); + return { + preStartDomMutations: sample.domMutations, + preStartIpcCalls: sample.ipcCalls, + quietMs: QUIET_MS, + sqlStatements: sample.sqlStatements, + waitedMs, + }; +} + +function sleepUntil(epochMs: number): Promise { + const remainingMs = epochMs - Date.now(); + return remainingMs > 0 + ? new Promise((resolve) => setTimeout(resolve, remainingMs)) + : Promise.resolve(); +} + +const QUERY_TRACE_KEY = '__iptvnatorJourneySearchQueryTrace'; + +/** + * Records every trace event of the query method in the main process, with + * the term and result length the preload's summaries carry and the main + * process's arrival epoch (the clock the IPC capture stamps its sentinels + * with). The record uses it to prove the final term's query completed + * before the settle, so results of an earlier term cannot end the journey. + * The summaries hold the search term and counts only. + */ +async function traceQueryCalls( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + ({ ipcMain }, input) => { + const entries: unknown[] = []; + (globalThis as unknown as Record)[input.key] = + entries; + ipcMain.on(input.channel, (_event, payload: unknown) => { + const record = payload as Record | null; + if (record?.['method'] !== input.method) return; + const args = record['args'] as { items?: unknown[] } | null; + const result = record['result'] as { length?: unknown } | null; + const term = args?.items?.[0]; + entries.push({ + epochMs: Date.now(), + phase: String(record['phase']), + resultLength: + typeof result?.length === 'number' + ? result.length + : null, + term: typeof term === 'string' ? term : null, + }); + }); + }, + { + channel: JOURNEY_RENDERER_API_TRACE_CHANNEL, + key: QUERY_TRACE_KEY, + method: SEARCH_JOURNEY_QUERY_METHOD, + } + ); +} + +const SENTINEL_SQL_KEY = '__iptvnatorJourneySearchSentinelSql'; + +/** + * Reads `main.sqlStatements` in the main process when the start and the end + * sentinel arrive, so the SQL counter covers exactly the IPC capture's + * window: database work just before the first key or after the settle is + * not counted. The gate's `invokeHandler` runs the counters handler + * synchronously, so the value is the total at the sentinel's arrival even + * though it is stored when the promise settles. + */ +async function stampSqlAtSentinels( + electronApp: ElectronApplication, + probeOptions: SearchJourneyProbeOptions +): Promise { + await electronApp.evaluate( + ({ ipcMain }, input) => { + const target = globalThis as unknown as Record; + const gate = target[input.gateKey] as { + invokeHandler: (channel: string) => Promise; + }; + const state: Record<'end' | 'start', number | null> = { + end: null, + start: null, + }; + target[input.key] = state; + ipcMain.on(input.channel, (_event, payload: unknown) => { + const record = payload as Record | null; + if ( + record?.['method'] !== input.sentinelMethod || + record['phase'] !== 'start' + ) { + return; + } + const args = JSON.stringify(record['args'] ?? null); + const which = args.includes(input.startId) + ? 'start' + : args.includes(input.endId) + ? 'end' + : null; + if (which === null || state[which] !== null) return; + void gate + .invokeHandler(input.countersChannel) + .then((snapshot) => { + const value = ( + snapshot as { counters?: Record } + )?.counters?.[input.sqlCounter]; + state[which] = typeof value === 'number' ? value : null; + }); + }); + }, + { + channel: JOURNEY_RENDERER_API_TRACE_CHANNEL, + countersChannel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL, + endId: probeOptions.endSentinelId, + gateKey: JOURNEY_RENDERER_GATE_KEY, + key: SENTINEL_SQL_KEY, + sentinelMethod: probeOptions.sentinelMethod, + sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS, + startId: probeOptions.startSentinelId, + } + ); +} + +async function readQueryTrace( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate( + (_electron, key) => + JSON.parse( + JSON.stringify( + (globalThis as unknown as Record)[key] + ) + ) as SearchJourneyQueryTraceEntry[], + QUERY_TRACE_KEY + ); +} + +/** + * What a failed settle saw: the bridge calls of the search, renderer errors + * (a search that threw shows the same empty view as one that found + * nothing), SQL statements before each key and now, and the traced query + * calls with their terms and result lengths. + */ +async function describeSettleFailure( + electronApp: ElectronApplication, + samples: readonly SearchJourneyActivitySample[], + consoleErrors: readonly string[] +): Promise { + const [capture] = await peekJourneyMainIpcCaptures(electronApp, [ + SEARCH_JOURNEY_MAIN_IPC_STATE_KEY, + ]); + const now = await sampleActivity(electronApp); + const queryTrace = await readQueryTrace(electronApp); + return JSON.stringify({ + bridgeCalls: capture.callsByMethod, + consoleErrors, + queryTrace, + sqlBeforeKeysAndNow: [...samples, now].map( + (entry) => entry.sqlStatements + ), + }); +} + +/** Rail link → global search, then focus the header box. Not measured. */ +async function openGlobalSearch(page: Page, timeoutMs: number): Promise { + await page + .getByRole('link', { name: 'Global search', exact: true }) + .click({ timeout: timeoutMs }); + // A router navigation, not a document load: wait on the path itself. + await page + .waitForFunction( + (path) => location.pathname.endsWith(path), + SEARCH_JOURNEY_ROUTE_PATH, + { timeout: timeoutMs } + ) + .catch((failure: unknown) => { + throw new Error( + `${ERROR_PREFIX}-route-not-reached: ${page.url()} (${String(failure)})` + ); + }); + const input = page.locator(SEARCH_JOURNEY_INPUT_SELECTOR); + await input.waitFor({ state: 'visible', timeout: timeoutMs }); + await input.focus({ timeout: timeoutMs }); +} + +export async function measureSearchJourney( + session: LaunchJourneySession, + timeoutMs: number +): Promise { + const { electronApp, mainWindow } = session; + const query = SEARCH_JOURNEY_QUERY; + const probeOptions = createSearchJourneyProbeOptions(query); + // Kept for the message of a failed settle. + const consoleErrors: string[] = []; + mainWindow.on('console', (message) => { + if (message.type() === 'error' && consoleErrors.length < 10) { + consoleErrors.push(message.text().slice(0, 300)); + } + }); + await blockJourneyExternalArtwork(electronApp); + await traceQueryCalls(electronApp); + await stampSqlAtSentinels(electronApp, probeOptions); + await openGlobalSearch(mainWindow, timeoutMs); + await installJourneyMainIpcCapture(electronApp, { + channel: JOURNEY_RENDERER_API_TRACE_CHANNEL, + sentinelId: probeOptions.endSentinelId, + sentinelMethod: probeOptions.sentinelMethod, + startSentinelId: probeOptions.startSentinelId, + stateKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY, + }); + await armSearchJourneyProbe(mainWindow, probeOptions); + const settle = await waitForSearchQuiet( + electronApp, + mainWindow, + probeOptions.stateKey + ); + await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY); + const focused = await mainWindow + .locator(SEARCH_JOURNEY_INPUT_SELECTOR) + .evaluate((input) => input === document.activeElement); + if (!focused) { + throw new Error(`${ERROR_PREFIX}-input-not-focused`); + } + // One `keyboard.type` per character on a fixed schedule, so the + // main-process sample before each key sits between two keystrokes. + const samples: SearchJourneyActivitySample[] = []; + const firstKeyAtMs = Date.now(); + for (let position = 0; position < query.length; position += 1) { + await sleepUntil(firstKeyAtMs + position * SEARCH_JOURNEY_KEY_DELAY_MS); + samples.push(await sampleActivity(electronApp)); + await mainWindow.keyboard.type(query[position]); + } + const renderer = await waitForSearchJourneyProbe( + mainWindow, + probeOptions.stateKey, + timeoutMs + ).catch(async (failure: unknown) => { + throw new Error( + `${String(failure)} ${await describeSettleFailure(electronApp, samples, consoleErrors)}` + ); + }); + const ipc = await readJourneyMainIpcCapture( + electronApp, + SEARCH_JOURNEY_MAIN_IPC_STATE_KEY, + 10_000 + ); + samples.push(await sampleActivity(electronApp)); + await new Promise((resolve) => + setTimeout(resolve, AFTER_SETTLED_WINDOW_MS) + ); + return { + afterSettled: await sampleActivity(electronApp), + afterSettledWindowMs: AFTER_SETTLED_WINDOW_MS, + externalArtworkCancelled: + await readJourneyExternalArtworkCancelled(electronApp), + ipc, + keyDelayMs: SEARCH_JOURNEY_KEY_DELAY_MS, + pid: session.launch.pid, + query, + queryTrace: await readQueryTrace(electronApp), + sqlAtSentinels: await electronApp.evaluate( + (_electron, key) => + JSON.parse( + JSON.stringify( + (globalThis as unknown as Record)[key] + ) + ) as SearchJourneyMeasurement['sqlAtSentinels'], + SENTINEL_SQL_KEY + ), + renderer, + samples, + settle, + }; +} diff --git a/apps/electron-backend-e2e/src/journeys/search.journey.ts b/apps/electron-backend-e2e/src/journeys/search.journey.ts new file mode 100644 index 000000000..5663b4f27 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/search.journey.ts @@ -0,0 +1,79 @@ +import { test } from '@playwright/test'; + +import type { JourneyIterationRecord } from '../performance/journey-summary'; +import { + SEARCH_JOURNEY_ID, + SEARCH_JOURNEY_UNAVAILABLE_COUNTERS, + toSearchIterationRecord, +} from '../performance/search-journey-record'; +import { + JOURNEY_ITERATION_TIMEOUT_MS, + JOURNEY_MEASURED_ITERATIONS, + JOURNEY_WARMUP_ITERATIONS, + logJourneyIteration, + writeJourneyRunEntry, +} from './journey-run'; +import { + LAUNCH_JOURNEY_MOCK_ORIGIN, + removeLaunchJourneyProfile, + runLaunchJourney, + seedLaunchJourneyProfile, +} from './launch-journey-app'; +import { + measureSearchJourney, + SEARCH_JOURNEY_SEED, +} from './search-journey-app'; + +/** + * J4 "Search": type a six-character query into the header search box on + * /workspace/search until the global search results have settled. Every + * iteration is a fresh J1 launch on a copy of the seeded profile (one M3U + * source and the mock's 12,000-item `large` Xtream catalog), with the + * main-process counters on so SQL statements are counted. + * Contract: docs/architecture/performance-journeys.md. + */ +test.describe.configure({ mode: 'serial' }); + +test('J4 search', async () => { + const iterations: JourneyIterationRecord[] = []; + let electronVersion = 'unknown'; + const templateDirectory = await seedLaunchJourneyProfile( + LAUNCH_JOURNEY_MOCK_ORIGIN, + SEARCH_JOURNEY_SEED + ); + try { + const total = JOURNEY_WARMUP_ITERATIONS + JOURNEY_MEASURED_ITERATIONS; + for (let index = 0; index < total; index += 1) { + const warmup = index < JOURNEY_WARMUP_ITERATIONS; + const { continuation, launch } = await runLaunchJourney( + templateDirectory, + JOURNEY_ITERATION_TIMEOUT_MS, + // SQL statements are a J4 counter, so unlike J2 and J3 the + // launch runs with the main-process counters and SQL hook. + { idleWindowMs: null, mainCounters: true }, + (session) => + measureSearchJourney( + session, + JOURNEY_ITERATION_TIMEOUT_MS + ).catch((failure: unknown) => { + throw new Error( + `iteration ${index}: ${String(failure)}` + ); + }) + ); + electronVersion = launch.electronVersion; + const record = toSearchIterationRecord(index, warmup, continuation); + iterations.push(record); + logJourneyIteration(SEARCH_JOURNEY_ID, record); + } + } finally { + await removeLaunchJourneyProfile(templateDirectory); + } + + await writeJourneyRunEntry( + SEARCH_JOURNEY_ID, + iterations, + SEARCH_JOURNEY_UNAVAILABLE_COUNTERS, + electronVersion + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-external-artwork.ts b/apps/electron-backend-e2e/src/performance/journey-external-artwork.ts new file mode 100644 index 000000000..e9cd7ce12 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-external-artwork.ts @@ -0,0 +1,51 @@ +import type { ElectronApplication } from '@playwright/test'; + +/** + * The mock's generated catalogs point channel, category, poster and cover + * artwork at picsum.photos. Journeys that render that artwork (J3, J4) + * cancel those requests in the main process, so no request of the journey + * leaves the machine and an image never loads, or fails, at a different + * moment on a runner with a different network. Contract: + * docs/architecture/performance-journeys.md. + */ +const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork'; +const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*']; + +export async function blockJourneyExternalArtwork( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + ({ session }, input) => { + const target = globalThis as unknown as Record; + if (target[input.key] !== undefined) { + throw new Error('journey-artwork-block-installed'); + } + const state = { cancelled: 0 }; + target[input.key] = state; + // The app registers no onBeforeRequest listener of its own + // (only onBeforeSendHeaders), so this replaces nothing. + session.defaultSession.webRequest.onBeforeRequest( + { urls: input.urls }, + (_details, callback) => { + state.cancelled += 1; + callback({ cancel: true }); + } + ); + }, + { key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS } + ); +} + +export async function readJourneyExternalArtworkCancelled( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate( + (_electron, key) => + ( + (globalThis as unknown as Record)[key] as { + cancelled: number; + } + ).cancelled, + EXTERNAL_ARTWORK_STATE_KEY + ); +} diff --git a/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts b/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts index acd9f58af..d109a2d8f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts +++ b/apps/electron-backend-e2e/src/performance/journey-launch-environment.ts @@ -2,8 +2,8 @@ * Instrumentation flags a journey launch sets on the Electron process. * * Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its - * IPC counters. Only J1 records the main-process counters: - * `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and + * IPC counters. Only J1 and J4 (for its SQL count) record the main-process + * counters: `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and * `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite * statement to count it (see journey-main-counters.ts). A journey that * continues from the launch without reading them (J2) leaves both off, so diff --git a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts index 7b02bc5f0..4d07ef2b8 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts @@ -127,7 +127,7 @@ test('rejects snapshots that were not frozen at the moments they claim', () => { ); }); -test('only the launch journey opts into SQL statement counting', () => { +test('only the launch and search journeys opt into SQL statement counting', () => { // The import benchmarks also run with IPTVNATOR_PERF_CAPTURE=1; the SQL // hook wraps every row of a bulk insert, so they must not enable it. const sourceRoot = resolve(__dirname, '..'); @@ -146,12 +146,25 @@ test('only the launch journey opts into SQL statement counting', () => { assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [ join('performance', 'journey-launch-environment.ts'), ]); - // ...and only J1's launch asks for them there. Another journey that + // ...and only J1's launch and J4, which records the count as + // renderer.sqlStatementsPerSearch, ask for them. Another journey that // passed `mainCounters: true` to runLaunchJourney would be measured // under the statement hook without recording its count. - assert.deepEqual(containing(/mainCounters:\s*true/), [ + assert.deepEqual(containing(/mainCounters:\s*true/).sort(), [ join('journeys', 'launch-journey-app.ts'), + join('journeys', 'search.journey.ts'), ]); + assert.match( + readFileSync(join(sourceRoot, 'journeys', 'search.journey.ts'), 'utf8'), + /\{ idleWindowMs: null, mainCounters: true \}/ + ); + assert.match( + readFileSync( + join(sourceRoot, 'performance', 'search-journey-record.ts'), + 'utf8' + ), + /SQL_STATEMENTS: 'renderer\.sqlStatementsPerSearch'/ + ); const launchApp = readFileSync( join(sourceRoot, 'journeys', 'launch-journey-app.ts'), 'utf8' diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts index e203d4540..2cbae397d 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts @@ -14,6 +14,7 @@ function gate( blankLoadedEpochMs: 1_050, errors: [], gatedEpochMs: 1_020, + didFinishLoadHeldOnBlank: 1, gatedMethod: 'loadFile', passThroughLoads: 0, readyToShowHeldOnBlank: 1, diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs index be80ae151..baca328c4 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs @@ -22,6 +22,11 @@ * therefore drops `ready-to-show` while the window is on `about:blank`; * Electron emits it again for the real document's first paint, because the * window is still hidden, which is the moment production sees. + * The app also shows its window at the main frame's `did-finish-load` + * when that comes first, so the gate keeps the app's `did-finish-load` + * listeners (those registered before the gated load) away from the + * about:blank load too. Electron's own listener that resolves + * `loadURL(about:blank)` is registered later and still runs. * * With `ipcMain` passed in, the gate also keeps the listeners registered * with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a @@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) { }; } +function holdDidFinishLoadWhileBlank(window, state) { + const contents = window.webContents; + if ( + !contents || + typeof contents.emit !== 'function' || + typeof contents.rawListeners !== 'function' + ) { + return; + } + const appListeners = contents.rawListeners('did-finish-load'); + const originalEmit = contents.emit; + contents.emit = function gatedContentsEmit(eventName, ...args) { + if (eventName !== 'did-finish-load' || !isShowingBlank(window)) { + return originalEmit.call(this, eventName, ...args); + } + state.didFinishLoadHeldOnBlank += 1; + const attached = this.rawListeners(eventName); + const held = appListeners.filter((listener) => + attached.includes(listener) + ); + for (const listener of held) this.removeListener(eventName, listener); + try { + return originalEmit.call(this, eventName, ...args); + } finally { + // Raw listeners keep their `once` wrappers, so a re-added once + // listener still fires once for the real document. + for (const listener of held) + this.prependListener(eventName, listener); + } + }; +} + function tapIpcHandlers(ipcMain, channels) { const handlers = new Map(); const originalHandle = ipcMain.handle; @@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) { errors: [], gatedEpochMs: null, gatedMethod: null, + didFinishLoadHeldOnBlank: 0, passThroughLoads: 0, readyToShowHeldOnBlank: 0, releasedEpochMs: null, @@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) { state.gatedEpochMs = now(); state.gatedMethod = method; holdReadyToShowWhileBlank(this, state); + holdDidFinishLoadWhileBlank(this, state); try { await this.webContents.loadURL(BLANK_URL); state.blankLoadedEpochMs = now(); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts index a6e44978d..8eb336dee 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts @@ -4,6 +4,7 @@ import test from 'node:test'; interface GateState { blankLoadedEpochMs: number | null; + didFinishLoadHeldOnBlank: number; errors: string[]; gatedEpochMs: number | null; gatedMethod: string | null; @@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as function createEmittingBrowserWindow(log: string[]) { class EmittingBrowserWindow extends EventEmitter { url = ''; - webContents = { + webContents = Object.assign(new EventEmitter(), { getURL: () => this.url, loadURL: async (url: string) => { this.url = url; log.push(`webContents.loadURL:${url}`); }, - }; + }); async loadFile(file: string): Promise { this.url = `file:///${file}`; log.push(`loadFile:${file}`); @@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real assert.equal(api.state.readyToShowHeldOnBlank, 1); }); +test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => { + const log: string[] = []; + const EmittingBrowserWindow = createEmittingBrowserWindow(log); + const api = gateModule.installJourneyRendererGate( + EmittingBrowserWindow as unknown as { + prototype: Record; + }, + {}, + { timeoutMs: 60_000 } + ); + const window = new EmittingBrowserWindow(); + // The app shows its window at the first did-finish-load. + window.webContents.once('did-finish-load', () => + log.push('app:did-finish-load') + ); + const load = window.loadFile('index.html'); + await settle(); + // Registered after the gated load, like Electron's own listener that + // resolves loadURL(about:blank). + window.webContents.on('did-finish-load', () => + log.push('electron:did-finish-load') + ); + window.webContents.emit('did-finish-load'); + assert.equal(api.state.didFinishLoadHeldOnBlank, 1); + + api.release(); + await load; + window.webContents.emit('did-finish-load'); + window.webContents.emit('did-finish-load'); + + assert.deepEqual(log, [ + 'webContents.loadURL:about:blank', + 'electron:did-finish-load', + 'loadFile:index.html', + 'app:did-finish-load', + 'electron:did-finish-load', + 'electron:did-finish-load', + ]); + assert.equal(api.state.didFinishLoadHeldOnBlank, 1); +}); + test('taps ipcMain.handle for the counters channel and passes registrations through', async () => { const registered: string[] = []; const ipcMain: FakeIpcMain = { diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index c42b35f59..37f86d4d5 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -341,6 +341,8 @@ test('keeps summing shifts without recent input after the first-card cutoff unti [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]', }, @@ -798,6 +800,13 @@ test('drops performance entries from before the click and keeps recent-input shi { entryType: 'layout-shift', hadRecentInput: true, + sources: [ + { + currentRect: { height: 40, width: 300, x: 48, y: 152 }, + node: fixture.card, + previousRect: { height: 40, width: 320, x: 0, y: 100 }, + }, + ], startTime: now(), value: 0.25, }, @@ -823,6 +832,25 @@ test('drops performance entries from before the click and keeps recent-input shi assert.equal(state.final, true); assert.equal(state.counters.layoutShiftScore, 0.125); assert.equal(state.counters.recentInputLayoutShiftScore, 0.25); + // Each counted shift keeps its nodes; the pre-click ones are not listed. + assert.deepEqual( + state.shifts.map((shift) => [ + shift.hadRecentInput, + shift.value, + shift.sources.map((source) => [ + source.deltaX, + source.deltaY, + source.deltaWidth, + ]), + ]), + [ + [true, 0.25, [[48, 52, -20]]], + [false, 0.125, []], + ] + ); + assert.equal(state.shiftCount, 2); + assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0)); + assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/); // J2 has no settle window: the observers close at the cutoff. assert.equal(state.settle.status, 'disabled'); assert.equal(state.counters.layoutShiftScoreSettled, 0); @@ -869,6 +897,8 @@ test('rejects a click start whose sentinel could not be sent', async () => { const started = { ...state, sentinel: { epochMs: 1, status: 'sent' as const }, + shiftCount: 0, + shifts: [], }; assert.throws( () => assertJourneyRendererProbeState(started), diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts index 799aeeb48..3fcf9142f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts @@ -17,9 +17,9 @@ export interface FakeEntry { entryType: string; hadRecentInput?: boolean; sources?: { - currentRect: { height: number; y: number }; + currentRect: { height: number; width?: number; x?: number; y: number }; node: unknown; - previousRect: { height: number; y: number }; + previousRect: { height: number; width?: number; x?: number; y: number }; }[]; startTime: number; value?: number; diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 71a321a23..7ee9bac31 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -166,12 +166,24 @@ export interface JourneyRendererProbeCounters { recentInputLayoutShiftScore: number; } +/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */ +export interface JourneyRendererProbeShift { + readonly hadRecentInput: boolean; + /** Entry start minus the journey start (navigation start for J1). */ + readonly sinceStartMs: number; + /** `tag.class[data-test-id]` and the move of each source. */ + readonly sources: JourneyRendererProbeLateShift['sources']; + readonly value: number; +} + export interface JourneyRendererProbeLateShift { /** Entry start minus the first-card terminal epoch. */ readonly afterFirstCardMs: number; - /** `tag.class[data-test-id]` and the vertical move of each source. */ + /** `tag.class[data-test-id]` and the move of each source. */ readonly sources: readonly { readonly deltaHeight: number; + readonly deltaWidth: number; + readonly deltaX: number; readonly deltaY: number; readonly node: string; }[]; @@ -237,6 +249,13 @@ export interface JourneyRendererProbeState { readonly epochMs: number | null; readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent'; }; + /** Every shift counted until the cutoff; `shifts` keeps the first 20. */ + shiftCount: number; + /** + * The first 20 shifts counted until the cutoff, with the nodes that + * moved, so a layout-shift score can be traced to its components. + */ + shifts: JourneyRendererProbeShift[]; /** `final` freezes the first-card counters; the settle window ends later. */ settle: { /** Mutation records under the settle root after the cutoff. */ @@ -329,6 +348,8 @@ export function journeyRendererProbeScript( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: null, status: 'not-sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, @@ -389,6 +410,7 @@ export function journeyRendererProbeScript( for (const entry of entries) { const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; + sources?: readonly LateShiftSource[]; value?: number; }; if ( @@ -397,6 +419,18 @@ export function journeyRendererProbeScript( ) { continue; } + state.shiftCount += 1; + if (state.shifts.length < 20) { + state.shifts.push({ + hadRecentInput: shift.hadRecentInput === true, + sinceStartMs: + performance.timeOrigin + + entry.startTime - + (state.start?.epochMs ?? performance.timeOrigin), + sources: (shift.sources ?? []).map(describeSource), + value: shift.value, + }); + } if (shift.hadRecentInput === true) { state.counters.recentInputLayoutShiftScore += shift.value; continue; @@ -530,10 +564,11 @@ export function journeyRendererProbeScript( state.idle.status = 'done'; }, idle.durationMs); }; + type ShiftRect = { height: number; width?: number; x?: number; y: number }; type LateShiftSource = { - currentRect?: { height: number; y: number }; + currentRect?: ShiftRect; node?: Node | null; - previousRect?: { height: number; y: number }; + previousRect?: ShiftRect; }; const describeSource = (source: LateShiftSource) => { const node = source.node; @@ -550,9 +585,13 @@ export function journeyRendererProbeScript( } const before = source.previousRect; const after = source.currentRect; + const delta = (key: keyof ShiftRect) => + before && after ? (after[key] ?? 0) - (before[key] ?? 0) : 0; return { - deltaHeight: before && after ? after.height - before.height : 0, - deltaY: before && after ? after.y - before.y : 0, + deltaHeight: delta('height'), + deltaWidth: delta('width'), + deltaX: delta('x'), + deltaY: delta('y'), node: label, }; }; diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index 02bfe3d21..b5123cd3b 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -54,6 +54,8 @@ function measurement( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: 2_601, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 37, epochMs: 3_180.06, @@ -64,6 +66,8 @@ function measurement( sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, @@ -112,6 +116,7 @@ function measurement( blankLoadedEpochMs: 1_050, errors: [], gatedEpochMs: 1_020, + didFinishLoadHeldOnBlank: 1, gatedMethod: 'loadFile', passThroughLoads: 0, readyToShowHeldOnBlank: 1, @@ -185,6 +190,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- 'main.startupPhases': 9, }); assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1); + assert.equal(record.evidence['rendererGateDidFinishLoadHeldOnBlank'], 1); assert.deepEqual(record.evidence['epochs'], { firstCard: 2_600.04, firstCardPaint: 2_650, @@ -208,6 +214,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- sources: [ { deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, deltaY: -240, node: 'section.dashboard-rail', }, diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index 2349648fe..f0ec0b053 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -185,6 +185,8 @@ export function toLaunchIterationRecord( mainCountersAtRead: mainCounters.counters, rendererGateReadyToShowHeldOnBlank: measurement.gate.readyToShowHeldOnBlank, + rendererGateDidFinishLoadHeldOnBlank: + measurement.gate.didFinishLoadHeldOnBlank, ipcCallsByMethod: ipc.callsByMethod, ipcSerialDepth: serialDepth, ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions, diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts index 95cb2fb4d..f17f7b390 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts @@ -59,6 +59,23 @@ function measurement( preStart: { domMutations: 4, lastMutationEpochMs: 9_100 }, schemaVersion: 1, sentinel: { epochMs: 10_080.5, status: 'sent' }, + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.26, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.22106, + }, + ], settle: { domMutations: 0, epochMs: null, @@ -147,6 +164,24 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters }); assert.deepEqual(record.evidence['layoutShift'], { recentInput: 0.221, + // More shifts were counted than listed. + shiftCount: 23, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.3, + sources: [ + { + deltaHeight: 0, + deltaWidth: 0, + deltaX: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.2211, + }, + ], withoutRecentInput: 0, }); assert.deepEqual(record.evidence['httpRequestsByRoute'], { diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts index 4d68bdcaa..ef9e61403 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts @@ -184,6 +184,17 @@ export function toOpenSourceIterationRecord( recentInput: roundThousandth( renderer.counters.recentInputLayoutShiftScore ), + // Every counted shift; `shifts` lists the first 20. + shiftCount: renderer.shiftCount, + // The first 20 counted shifts and the nodes that moved. + shifts: renderer.shifts.map((shift) => + Object.freeze({ + hadRecentInput: shift.hadRecentInput, + sinceStartMs: roundTenth(shift.sinceStartMs), + sources: shift.sources, + value: Math.round(shift.value * 10_000) / 10_000, + }) + ), withoutRecentInput: roundThousandth( renderer.counters.layoutShiftScore ), diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 0ff7caa34..91a8701f4 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -10,6 +10,7 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; interface TargetConfiguration { configurations?: Record>; + defaultConfiguration?: string; dependsOn?: unknown; executor?: unknown; options?: Record; @@ -162,7 +163,12 @@ test('only the web performance build installs the tick counter the journeys read for (const [name, configuration] of Object.entries( webProject.targets['build'].configurations ?? {} )) { - if (name === 'electron-performance') continue; + if ( + name === 'electron-performance' || + name === 'electron-performance-zoneless' + ) { + continue; + } assert.doesNotMatch( JSON.stringify(configuration['fileReplacements'] ?? []), /environment\.performance/, @@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read } }); +// Plan item C6 measures zoneless change detection behind a build-time flag: +// each *-zoneless configuration is its base configuration plus one swap of +// the change-detection providers, and nothing else selects that swap. +test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => { + const configurations = webProject.targets['build'].configurations ?? {}; + const zonelessReplacement = { + replace: 'apps/web/src/environments/change-detection.providers.ts', + with: 'apps/web/src/environments/change-detection.providers.zoneless.ts', + }; + + for (const base of ['electron-performance', 'electron-e2e']) { + const baseConfiguration = configurations[base]; + const zoneless = configurations[`${base}-zoneless`]; + assert.ok(zoneless, `web:build must define ${base}-zoneless`); + assert.ok(baseConfiguration, `web:build must define ${base}`); + const { fileReplacements: baseReplacements, ...baseRest } = + baseConfiguration; + const { fileReplacements, ...rest } = zoneless; + assert.deepEqual(rest, baseRest, base); + assert.deepEqual(fileReplacements, [ + ...((baseReplacements as unknown[] | undefined) ?? []), + zonelessReplacement, + ]); + } + for (const [name, configuration] of Object.entries(configurations)) { + if (name.endsWith('-zoneless')) continue; + assert.doesNotMatch( + JSON.stringify(configuration['fileReplacements'] ?? []), + /change-detection\.providers/, + name + ); + } + assert.equal( + webProject.targets['build'].defaultConfiguration, + 'production' + ); +}); + test('the resolved web build cache output is the renderer directory', () => { const task = readResolvedWebBuildTask(); diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts index ee228c1c8..2b4246025 100644 --- a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts @@ -74,6 +74,8 @@ function measurement( preStart: { domMutations: 53, lastMutationEpochMs: 8_900 }, schemaVersion: 1, sentinel: { epochMs: 10_350, status: 'sent' }, + shiftCount: 0, + shifts: [], settle: { domMutations: 0, epochMs: null, diff --git a/apps/electron-backend-e2e/src/performance/search-journey-probe.spec.ts b/apps/electron-backend-e2e/src/performance/search-journey-probe.spec.ts new file mode 100644 index 000000000..7c7d9e66d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/search-journey-probe.spec.ts @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { JSDOM } from 'jsdom'; + +import { + JOURNEY_CD_TICK_COUNTER_KEY, + JOURNEY_IPC_SENTINEL_METHOD, + JOURNEY_PLAYBACK_PROBE_STATE_KEY, + JOURNEY_PROBE_STATE_KEY, +} from './journey-renderer-probe'; +import { + installFakePerformance, + type FakeObserver, +} from './journey-renderer-probe.test-helpers'; +import { + assertSearchJourneyProbeState, + createSearchJourneyProbeOptions, + SEARCH_JOURNEY_END_SENTINEL_ID, + SEARCH_JOURNEY_PROBE_STATE_KEY, + SEARCH_JOURNEY_START_SENTINEL_ID, + searchJourneyProbeScript, + type SearchJourneyProbeOptions, + type SearchJourneyProbeState, +} from './search-journey-probe'; + +const QUERY = 'system'; + +interface SearchFixture { + readonly bridgeCalls: unknown[]; + readonly input: HTMLInputElement; + readonly observers: FakeObserver[]; + readonly results: HTMLElement; + readonly state: () => SearchJourneyProbeState; + readonly ticks: { count: number }; + readonly window: JSDOM['window']; +} + +function createSearchFixture( + overrides: Partial = {} +): SearchFixture { + const dom = new JSDOM( + ` + + +
+
`, + { + pretendToBeVisual: true, + runScripts: 'outside-only', + url: 'http://localhost/workspace/search', + } + ); + const { window } = dom; + const observers: FakeObserver[] = []; + const bridgeCalls: unknown[] = []; + installFakePerformance(window, observers); + // See journey-renderer-probe.test-helpers.ts: tsx keeps names. + Object.defineProperty(window, '__name', { + configurable: true, + value: (target: unknown) => target, + }); + Object.defineProperty(window, 'electron', { + configurable: true, + value: Object.freeze({ + [JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => { + bridgeCalls.push(id); + return Promise.resolve(null); + }, + }), + }); + const ticks = { count: 40 }; + Object.defineProperty(window, JOURNEY_CD_TICK_COUNTER_KEY, { + configurable: true, + value: ticks, + }); + const options = { + ...createSearchJourneyProbeOptions(QUERY), + quietMs: 30, + ...overrides, + }; + window.eval( + `(${searchJourneyProbeScript.toString()})(${JSON.stringify(options)})` + ); + const { document } = window; + return { + bridgeCalls, + input: document.querySelector('input') as HTMLInputElement, + observers, + results: document.querySelector('.results-container') as HTMLElement, + state: () => + JSON.parse( + JSON.stringify( + (window as unknown as Record)[ + options.stateKey + ] + ) + ) as SearchJourneyProbeState, + ticks, + window, + }; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** A keydown in the input plus what the shell renders for it. */ +async function typeKey(fixture: SearchFixture, key: string): Promise { + fixture.input.dispatchEvent( + new fixture.window.KeyboardEvent('keydown', { bubbles: true, key }) + ); + fixture.input.setAttribute('data-value', fixture.input.value + key); + fixture.input.value += key; + await wait(0); +} + +function setQuery(fixture: SearchFixture, query: string): void { + fixture.window.history.replaceState( + null, + '', + `/workspace/search?q=${encodeURIComponent(query)}` + ); +} + +function addCards(fixture: SearchFixture, count: number): void { + for (let index = 0; index < count; index += 1) { + fixture.results.append( + fixture.window.document.createElement('app-content-card') + ); + } +} + +async function waitForFinal(fixture: SearchFixture): Promise { + const deadline = Date.now() + 2_000; + while (!fixture.state().final && Date.now() < deadline) { + await wait(5); + } +} + +test('search options use their own state key, sentinels and the query length', () => { + const options = createSearchJourneyProbeOptions(QUERY); + assert.equal(options.keystrokes, 6); + assert.equal(options.query, QUERY); + assert.equal(options.quietMs, 200); + assert.equal(options.stateKey, SEARCH_JOURNEY_PROBE_STATE_KEY); + assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY); + assert.notEqual(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY); + assert.equal(options.startSentinelId, SEARCH_JOURNEY_START_SENTINEL_ID); + assert.equal(options.endSentinelId, SEARCH_JOURNEY_END_SENTINEL_ID); + assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD); + assert.equal(options.cdTickCounterKey, JOURNEY_CD_TICK_COUNTER_KEY); +}); + +test('starts at the first keydown in the search box and buckets work per key', async () => { + const fixture = createSearchFixture(); + fixture.results.setAttribute('data-before', '1'); + await wait(0); + // A key elsewhere does not start the journey. + fixture.window.document.getElementById('elsewhere')?.dispatchEvent( + new fixture.window.KeyboardEvent('keydown', { + bubbles: true, + key: 'x', + }) + ); + assert.equal(fixture.state().start, null); + assert.deepEqual(fixture.bridgeCalls, []); + + for (const key of QUERY) { + fixture.ticks.count += 1; + await typeKey(fixture, key); + } + let state = fixture.state(); + assert.equal(state.preStart.domMutations, 1); + assert.deepEqual(fixture.bridgeCalls, [SEARCH_JOURNEY_START_SENTINEL_ID]); + assert.deepEqual( + state.keystrokes.map((key) => key.key), + [...QUERY] + ); + assert.deepEqual( + state.keystrokes.map((key) => key.ticks), + [41, 42, 43, 44, 45, 46] + ); + // One attribute record per key, each in its own bucket. + assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 1]); + + // The debounced term lands: results for the final query. + setQuery(fixture, QUERY); + fixture.ticks.count += 4; + addCards(fixture, 3); + await waitForFinal(fixture); + state = assertSearchJourneyProbeState(fixture.state()); + assert.equal(state.settle.status, 'quiet'); + assert.equal(state.settle.query, QUERY); + assert.equal(state.settle.cardCount, 3); + assert.equal(state.settle.ticks, 50); + assert.equal(state.capabilities.changeDetectionTicks, 'counted'); + assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 4]); + assert.equal(state.counters.domMutations, 9); + assert.equal(state.firstResult?.query, QUERY); + assert.equal(state.firstResult?.cardCount, 3); + assert.ok( + (state.settle.confirmedEpochMs ?? 0) - (state.settle.epochMs ?? 0) >= 25 + ); + assert.deepEqual(fixture.bridgeCalls, [ + SEARCH_JOURNEY_START_SENTINEL_ID, + SEARCH_JOURNEY_END_SENTINEL_ID, + ]); +}); + +test('does not settle on results for an intermediate term or while loading', async () => { + const fixture = createSearchFixture(); + for (const key of QUERY) { + await typeKey(fixture, key); + } + // Results of an earlier term are a first result, not the settle. + setQuery(fixture, 'syst'); + addCards(fixture, 2); + await wait(80); + let state = fixture.state(); + assert.equal(state.final, false); + assert.equal(state.firstResult?.query, 'syst'); + + // The final term's spinner keeps the window closed. + setQuery(fixture, QUERY); + const spinner = fixture.window.document.createElement('div'); + spinner.className = 'loading-state'; + fixture.window.document + .querySelector('app-search-results') + ?.append(spinner); + await wait(80); + assert.equal(fixture.state().final, false); + + spinner.remove(); + await waitForFinal(fixture); + state = assertSearchJourneyProbeState(fixture.state()); + assert.equal(state.settle.query, QUERY); + assert.equal(state.settle.cardCount, 2); +}); + +test('a mutation inside the quiet window restarts it', async () => { + const fixture = createSearchFixture({ quietMs: 60 }); + for (const key of QUERY) { + await typeKey(fixture, key); + } + setQuery(fixture, QUERY); + addCards(fixture, 1); + await wait(30); + addCards(fixture, 1); + const lastMutationAt = Date.now(); + await waitForFinal(fixture); + const state = assertSearchJourneyProbeState(fixture.state()); + assert.equal(state.settle.cardCount, 2); + assert.ok(Date.now() - lastMutationAt >= 55); +}); + +test('fails the iteration when results never settle', async () => { + const fixture = createSearchFixture({ settleTimeoutMs: 40 }); + for (const key of QUERY) { + await typeKey(fixture, key); + } + await waitForFinal(fixture); + const state = fixture.state(); + assert.equal(state.settle.status, 'timeout'); + assert.throws( + () => assertSearchJourneyProbeState(state), + /search-journey-probe-invalid: settle-timeout/ + ); +}); + +test('flags a keystroke beyond the expected count', async () => { + const fixture = createSearchFixture({ keystrokes: 2 }); + await typeKey(fixture, 'a'); + await typeKey(fixture, 'b'); + await typeKey(fixture, 'c'); + assert.deepEqual(fixture.state().invalidReasons, ['unexpected-keystroke']); +}); + +test('counts shifts and long tasks from the first key until the settle only', async () => { + const fixture = createSearchFixture(); + const shifts = fixture.observers.find( + (observer) => observer.type === 'layout-shift' + ); + const tasks = fixture.observers.find( + (observer) => observer.type === 'longtask' + ); + assert.ok(shifts && tasks); + // Buffered launch entries before the first key are dropped. + shifts.emit([{ entryType: 'layout-shift', startTime: 0, value: 0.5 }]); + tasks.emit([{ duration: 60, entryType: 'longtask', startTime: 0 }]); + // jsdom's clock starts with the fixture: let that task end first. + await wait(100); + for (const key of QUERY) { + await typeKey(fixture, key); + } + const now = fixture.window.performance.now(); + shifts.emit([ + { + entryType: 'layout-shift', + hadRecentInput: true, + startTime: now, + value: 0.02, + }, + { entryType: 'layout-shift', startTime: now, value: 0.01 }, + ]); + tasks.emit([ + { duration: 30, entryType: 'longtask', startTime: now }, + { duration: 80, entryType: 'longtask', startTime: now }, + ]); + setQuery(fixture, QUERY); + addCards(fixture, 1); + await waitForFinal(fixture); + const state = assertSearchJourneyProbeState(fixture.state()); + assert.equal(state.counters.recentInputLayoutShiftScore, 0.02); + assert.equal(state.counters.layoutShiftScore, 0.01); + assert.equal(state.counters.longTasks, 1); + assert.deepEqual(state.longTaskDurationsMs, [80]); + assert.ok(shifts.disconnected && tasks.disconnected); +}); + +test('rejects a state without the tick counter or the end sentinel', () => { + assert.throws( + () => assertSearchJourneyProbeState({ schemaVersion: 1 }), + /search-journey-probe-incomplete/ + ); + const base = { + capabilities: { layoutShift: true, longTask: true }, + final: true, + invalidReasons: [], + schemaVersion: 1, + sentinel: { status: 'bridge-missing' }, + start: { sentinelStatus: 'sent' }, + }; + assert.throws( + () => assertSearchJourneyProbeState(base), + /search-journey-probe-sentinel-bridge-missing/ + ); + assert.throws( + () => + assertSearchJourneyProbeState({ + ...base, + capabilities: { layoutShift: false, longTask: true }, + sentinel: { status: 'sent' }, + }), + /search-journey-probe-observer-unavailable/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/search-journey-probe.ts b/apps/electron-backend-e2e/src/performance/search-journey-probe.ts new file mode 100644 index 000000000..3bef31100 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/search-journey-probe.ts @@ -0,0 +1,507 @@ +import type { Page } from '@playwright/test'; + +import { + JOURNEY_CD_TICK_COUNTER_KEY, + JOURNEY_IPC_SENTINEL_METHOD, +} from './journey-renderer-probe'; + +/** + * Renderer-side probe for J4 "Search" (docs/architecture/performance-journeys.md). + * + * Armed in the loaded `/workspace/search` document after the header search + * input has focus. A capture-phase `keydown` listener on `window`, which runs + * before every listener of the app, stamps each keystroke in the input; the + * first one starts the journey and sends the start sentinel + * (`cancelSourceProbe`, as in J2 and J3). DOM mutation records and + * change-detection ticks are bucketed by the keystroke they follow, so the + * debounce behaviour is visible per key. + * + * End: after the last expected keystroke, the results for the final term are + * shown (URL `q` equals the query, a result card is visible and no loading + * state is rendered) and then no DOM mutation arrives for `quietMs`. The + * settled moment is the last mutation before that quiet window; the counters + * run until the quiet window is confirmed, when the end sentinel is sent. A + * plain "quiet after the last keystroke" would close inside the shell's + * debounce before any query ran. Without a settle by `settleTimeoutMs` + * after the last keystroke the iteration is invalid. + * + * The script must stay self-contained: Playwright serializes it with + * `toString()`, so it may only use its argument and browser globals. + */ +export const SEARCH_JOURNEY_PROBE_STATE_KEY = '__iptvnatorJourneySearchProbe'; +export const SEARCH_JOURNEY_PROBE_SCHEMA_VERSION = 1; +export const SEARCH_JOURNEY_START_SENTINEL_ID = + '__iptvnator-journey-search-start__'; +export const SEARCH_JOURNEY_END_SENTINEL_ID = + '__iptvnator-journey-search-end__'; +/** The workspace shell header's search box. */ +export const SEARCH_JOURNEY_INPUT_SELECTOR = + 'app-workspace-shell-header .search-field input[type="search"]'; +/** A rendered global search result (grouped or flat). */ +export const SEARCH_JOURNEY_RESULT_SELECTOR = + 'app-search-results .results-container app-content-card'; +/** The search layout's spinner while a query runs. */ +export const SEARCH_JOURNEY_LOADING_SELECTOR = + 'app-search-results .loading-state'; +export const SEARCH_JOURNEY_ROUTE_PATH = '/workspace/search'; +export const SEARCH_JOURNEY_QUIET_MS = 200; +export const SEARCH_JOURNEY_SETTLE_TIMEOUT_MS = 15_000; + +export interface SearchJourneyProbeOptions { + readonly cdTickCounterKey: string; + readonly endSentinelId: string; + readonly inputSelector: string; + /** Keystrokes the test types; the settle waits for the last one. */ + readonly keystrokes: number; + readonly loadingSelector: string; + /** Term the URL `q` must carry when the results count as final. */ + readonly query: string; + readonly quietMs: number; + readonly resultSelector: string; + /** The route the results must be on; the renderer path ends with it. */ + readonly routePath: string; + readonly sentinelMethod: string; + /** Hard limit from the last keystroke to the settle. */ + readonly settleTimeoutMs: number; + readonly startSentinelId: string; + readonly stateKey: string; +} + +export interface SearchJourneyProbeKeystroke { + /** `min(event.timeStamp, listener time)` as epoch milliseconds. */ + readonly epochMs: number; + readonly key: string; + /** Running tick total at the keydown, before the app handles it. */ + readonly ticks: number | null; +} + +export type SentinelStatus = 'bridge-missing' | 'failed' | 'not-sent' | 'sent'; + +export interface SearchJourneyProbeState { + readonly capabilities: { + changeDetectionTicks: + 'counted' | 'pending' | 'unavailable-counter-missing'; + layoutShift: boolean; + longTask: boolean; + }; + readonly counters: { + domMutations: number; + layoutShiftScore: number; + longTasks: number; + /** Shifts with `hadRecentInput === true`; typing is input. */ + recentInputLayoutShiftScore: number; + }; + /** Mutation records after each keystroke, until the next or the end. */ + readonly domMutationsByKeystroke: number[]; + final: boolean; + /** First batch with a visible result card, for any term. */ + firstResult: { + readonly cardCount: number; + readonly epochMs: number; + readonly query: string | null; + } | null; + readonly invalidReasons: string[]; + readonly keystrokes: SearchJourneyProbeKeystroke[]; + readonly longTaskDurationsMs: number[]; + readonly preStart: { + domMutations: number; + lastMutationEpochMs: number | null; + }; + readonly schemaVersion: number; + sentinel: { epochMs: number | null; status: SentinelStatus }; + settle: { + cardCount: number; + /** When the quiet window was confirmed; the counters stop here. */ + confirmedEpochMs: number | null; + /** Last mutation batch before the quiet window: "settled". */ + epochMs: number | null; + query: string | null; + status: 'pending' | 'quiet' | 'timeout'; + ticks: number | null; + }; + start: { + readonly epochMs: number; + readonly pathname: string; + readonly sentinelStatus: SentinelStatus; + } | null; +} + +export function searchJourneyProbeScript( + options: SearchJourneyProbeOptions +): void { + const target = globalThis as unknown as Record; + if (target[options.stateKey] !== undefined) { + return; + } + const epoch = (): number => performance.timeOrigin + performance.now(); + const bridge = target['electron'] as Record | undefined; + const state: SearchJourneyProbeState = { + capabilities: { + changeDetectionTicks: 'pending', + layoutShift: false, + longTask: false, + }, + counters: { + domMutations: 0, + layoutShiftScore: 0, + longTasks: 0, + recentInputLayoutShiftScore: 0, + }, + domMutationsByKeystroke: [], + final: false, + firstResult: null, + invalidReasons: [], + keystrokes: [], + longTaskDurationsMs: [], + preStart: { domMutations: 0, lastMutationEpochMs: null }, + schemaVersion: 1, + sentinel: { epochMs: null, status: 'not-sent' }, + settle: { + cardCount: 0, + confirmedEpochMs: null, + epochMs: null, + query: null, + status: 'pending', + ticks: null, + }, + start: null, + }; + target[options.stateKey] = state; + const readTicks = (): number | null => { + const counter = target[options.cdTickCounterKey] as + { count?: unknown } | undefined; + return typeof counter?.count === 'number' ? counter.count : null; + }; + const callSentinel = (id: string): SentinelStatus => { + const method = bridge?.[options.sentinelMethod]; + if (typeof method !== 'function') return 'bridge-missing'; + try { + void Promise.resolve(method.call(bridge, id)).catch( + () => undefined + ); + return 'sent'; + } catch { + return 'failed'; + } + }; + const readQuery = (): string | null => + new URLSearchParams(location.search).get('q'); + const visibleCards = (): number => { + let count = 0; + for (const card of Array.from( + document.querySelectorAll(options.resultSelector) + )) { + if (card.getClientRects().length > 0) count += 1; + } + return count; + }; + + // Entries before the first keystroke belong to the launch (buffered + // entries included) and are dropped. + let fromEpochMs = Number.POSITIVE_INFINITY; + const shifts: PerformanceEntry[] = []; + const tasks: PerformanceEntry[] = []; + const observe = ( + type: string, + sink: PerformanceEntry[] + ): PerformanceObserver | null => { + try { + const observer = new PerformanceObserver((list) => { + if (!state.final) sink.push(...list.getEntries()); + }); + observer.observe({ type, buffered: true }); + return observer; + } catch { + return null; + } + }; + const shiftObserver = observe('layout-shift', shifts); + const taskObserver = observe('longtask', tasks); + state.capabilities.layoutShift = shiftObserver !== null; + state.capabilities.longTask = taskObserver !== null; + + const countEntries = (untilEpochMs: number): void => { + if (shiftObserver) shifts.push(...shiftObserver.takeRecords()); + if (taskObserver) tasks.push(...taskObserver.takeRecords()); + shiftObserver?.disconnect(); + taskObserver?.disconnect(); + for (const entry of shifts) { + const shift = entry as PerformanceEntry & { + hadRecentInput?: boolean; + value?: number; + }; + const at = performance.timeOrigin + entry.startTime; + if ( + typeof shift.value !== 'number' || + at < fromEpochMs || + at > untilEpochMs + ) { + continue; + } + if (shift.hadRecentInput === true) { + state.counters.recentInputLayoutShiftScore += shift.value; + } else { + state.counters.layoutShiftScore += shift.value; + } + } + // A task overlaps the window when it ends after the first keydown: + // the task that dispatched it still counts. + for (const entry of tasks) { + const at = performance.timeOrigin + entry.startTime; + if ( + entry.duration <= 50 || + at + entry.duration < fromEpochMs || + at > untilEpochMs + ) { + continue; + } + state.counters.longTasks += 1; + state.longTaskDurationsMs.push(entry.duration); + } + }; + + let quietTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; + let lastMutationEpochMs: number | null = null; + const accept = (count: number): void => { + if (count === 0) return; + if (state.start === null) { + state.preStart.domMutations += count; + state.preStart.lastMutationEpochMs = epoch(); + return; + } + state.counters.domMutations += count; + const bucket = state.keystrokes.length - 1; + state.domMutationsByKeystroke[bucket] = + (state.domMutationsByKeystroke[bucket] ?? 0) + count; + lastMutationEpochMs = epoch(); + }; + const ready = (): boolean => + location.pathname.endsWith(options.routePath) && + readQuery() === options.query && + document.querySelector(options.loadingSelector) === null && + visibleCards() > 0; + const end = (status: 'quiet' | 'timeout'): void => { + if (state.settle.status !== 'pending') return; + clearTimeout(quietTimer); + clearTimeout(timeoutTimer); + accept(mutationObserver.takeRecords().length); + mutationObserver.disconnect(); + const confirmedEpochMs = epoch(); + const ticks = readTicks(); + state.settle = { + cardCount: visibleCards(), + confirmedEpochMs, + epochMs: lastMutationEpochMs, + query: readQuery(), + status, + ticks, + }; + if (status === 'timeout') { + // What the ready condition saw, so a timeout says which part + // never held. + const input = document.querySelector(options.inputSelector); + state.invalidReasons.push( + `settle-timeout ${JSON.stringify({ + cards: visibleCards(), + input: + input instanceof HTMLInputElement ? input.value : null, + loading: + document.querySelector(options.loadingSelector) !== + null, + path: location.pathname.slice(-40), + q: readQuery(), + view: + document.querySelector( + 'app-search-results .results-container' + )?.firstElementChild?.className ?? null, + })}` + ); + } + state.capabilities.changeDetectionTicks = + ticks === null || state.keystrokes[0]?.ticks === null + ? 'unavailable-counter-missing' + : 'counted'; + countEntries(confirmedEpochMs); + const sentinelStatus = callSentinel(options.endSentinelId); + state.sentinel = { + epochMs: sentinelStatus === 'sent' ? epoch() : null, + status: sentinelStatus, + }; + window.removeEventListener('keydown', onKeydown, true); + state.final = true; + }; + const mutationObserver = new MutationObserver((records) => { + if (state.settle.status !== 'pending') return; + accept(records.length); + if (state.start === null) return; + if (state.firstResult === null) { + const cardCount = visibleCards(); + if (cardCount > 0) { + state.firstResult = { + cardCount, + epochMs: epoch(), + query: readQuery(), + }; + } + } + // Every batch after the last keystroke restarts the quiet window, + // which only opens once the final term's results are shown. + clearTimeout(quietTimer); + if (state.keystrokes.length >= options.keystrokes && ready()) { + quietTimer = setTimeout(() => end('quiet'), options.quietMs); + } + }); + mutationObserver.observe(document.documentElement ?? document, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + + // Capture phase on window runs before every listener of the app, so the + // start sentinel precedes any bridge call the first key causes, and the + // records queued before a key belong to the previous bucket. + const onKeydown = (event: Event): void => { + const origin = + event.target instanceof Element + ? event.target.closest(options.inputSelector) + : null; + if (origin === null || state.settle.status !== 'pending') return; + if (state.keystrokes.length >= options.keystrokes) { + state.invalidReasons.push('unexpected-keystroke'); + return; + } + accept(mutationObserver.takeRecords().length); + const listenerEpochMs = epoch(); + const eventEpochMs = performance.timeOrigin + event.timeStamp; + const epochMs = + Number.isFinite(eventEpochMs) && eventEpochMs <= listenerEpochMs + ? eventEpochMs + : listenerEpochMs; + if (state.start === null) { + state.start = { + epochMs, + pathname: location.pathname, + sentinelStatus: callSentinel(options.startSentinelId), + }; + fromEpochMs = epochMs; + } + state.keystrokes.push({ + epochMs, + key: (event as KeyboardEvent).key ?? '', + ticks: readTicks(), + }); + state.domMutationsByKeystroke.push(0); + if (state.keystrokes.length === options.keystrokes) { + timeoutTimer = setTimeout( + () => end('timeout'), + options.settleTimeoutMs + ); + } + }; + window.addEventListener('keydown', onKeydown, true); +} + +export function createSearchJourneyProbeOptions( + query: string +): SearchJourneyProbeOptions { + return { + cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, + endSentinelId: SEARCH_JOURNEY_END_SENTINEL_ID, + inputSelector: SEARCH_JOURNEY_INPUT_SELECTOR, + keystrokes: query.length, + loadingSelector: SEARCH_JOURNEY_LOADING_SELECTOR, + query, + quietMs: SEARCH_JOURNEY_QUIET_MS, + resultSelector: SEARCH_JOURNEY_RESULT_SELECTOR, + routePath: SEARCH_JOURNEY_ROUTE_PATH, + sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD, + settleTimeoutMs: SEARCH_JOURNEY_SETTLE_TIMEOUT_MS, + startSentinelId: SEARCH_JOURNEY_START_SENTINEL_ID, + stateKey: SEARCH_JOURNEY_PROBE_STATE_KEY, + }; +} + +export async function armSearchJourneyProbe( + page: Page, + options: SearchJourneyProbeOptions +): Promise { + await page.evaluate(searchJourneyProbeScript, options); +} + +export async function readSearchJourneyPreStartMutations( + page: Page, + stateKey: string +): Promise { + return page.evaluate((key) => { + const state = (globalThis as unknown as Record)[ + key + ] as { preStart?: { domMutations?: number } } | undefined; + const count = state?.preStart?.domMutations; + if (typeof count !== 'number') { + throw new Error('search-journey-probe-not-armed'); + } + return count; + }, stateKey); +} + +export async function waitForSearchJourneyProbe( + page: Page, + stateKey: string, + timeoutMs: number +): Promise { + await page.waitForFunction( + (key) => + ( + (globalThis as unknown as Record)[key] as + { final?: boolean } | undefined + )?.final === true, + stateKey, + { polling: 50, timeout: timeoutMs } + ); + const state = await page.evaluate( + (key) => + JSON.parse( + JSON.stringify( + (globalThis as unknown as Record)[key] + ) + ) as unknown, + stateKey + ); + return assertSearchJourneyProbeState(state); +} + +export function assertSearchJourneyProbeState( + value: unknown +): SearchJourneyProbeState { + const state = value as SearchJourneyProbeState | null; + if ( + !state || + state.schemaVersion !== SEARCH_JOURNEY_PROBE_SCHEMA_VERSION || + state.final !== true || + state.start === null + ) { + throw new Error('search-journey-probe-incomplete'); + } + if (state.invalidReasons.length > 0) { + throw new Error( + `search-journey-probe-invalid: ${state.invalidReasons.join(', ')}` + ); + } + if (state.start.sentinelStatus !== 'sent') { + throw new Error( + `search-journey-probe-start-sentinel-${state.start.sentinelStatus}` + ); + } + if (state.sentinel.status !== 'sent') { + throw new Error( + `search-journey-probe-sentinel-${state.sentinel.status}` + ); + } + // A zero from an observer that never ran is not a measurement. + if (!state.capabilities.layoutShift || !state.capabilities.longTask) { + throw new Error('search-journey-probe-observer-unavailable'); + } + return state; +} diff --git a/apps/electron-backend-e2e/src/performance/search-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/search-journey-record.spec.ts new file mode 100644 index 000000000..f14587e53 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/search-journey-record.spec.ts @@ -0,0 +1,451 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; +import { summarizeJourneyIterations } from './journey-summary'; +import type { SearchJourneyProbeState } from './search-journey-probe'; +import { + SEARCH_JOURNEY_COUNTER, + SEARCH_JOURNEY_UNAVAILABLE_COUNTERS, + SEARCH_JOURNEY_WALL_CLOCK, + toSearchIterationRecord, + type SearchJourneyActivitySample, + type SearchJourneyMeasurement, +} from './search-journey-record'; + +const QUERY = 'system'; + +function sample( + ipcCalls: number, + queryCalls: number, + sqlStatements: number +): SearchJourneyActivitySample { + return { ipcCalls, queryCalls, sqlStatements }; +} + +/** The shape of a debounced search: only the last key runs a query. */ +function measurement( + overrides: Partial = {}, + rendererOverrides: Partial = {} +): SearchJourneyMeasurement { + const keystrokes = [...QUERY].map((key, position) => ({ + epochMs: 10_000 + position * 100, + key, + ticks: 40 + position, + })); + const renderer: SearchJourneyProbeState = { + capabilities: { + changeDetectionTicks: 'counted', + layoutShift: true, + longTask: true, + }, + counters: { + domMutations: 402, + layoutShiftScore: 0.0004, + longTasks: 0, + recentInputLayoutShiftScore: 0.0011, + }, + domMutationsByKeystroke: [0, 0, 0, 0, 0, 402], + final: true, + firstResult: { cardCount: 100, epochMs: 11_180, query: QUERY }, + invalidReasons: [], + keystrokes, + longTaskDurationsMs: [], + preStart: { domMutations: 5, lastMutationEpochMs: 8_000 }, + schemaVersion: 1, + sentinel: { epochMs: 11_400, status: 'sent' }, + settle: { + cardCount: 100, + confirmedEpochMs: 11_399.6, + epochMs: 11_192.54, + query: QUERY, + status: 'quiet', + ticks: 54, + }, + start: { + epochMs: 10_000, + pathname: '/w/search', + sentinelStatus: 'sent', + }, + ...rendererOverrides, + }; + const ipc: JourneyMainIpcCaptureState = { + ambiguousTimelineCompletions: 0, + callsAfterSentinel: 0, + callsBeforeStart: 0, + callsBeforeSentinel: 1, + callsByMethod: { dbGlobalSearch: 1 }, + inFlightByMethod: {}, + installedEpochMs: 9_000, + malformedEvents: 0, + processStartEpochMs: 1_000, + senderIds: [1], + sentinel: { occurrences: 1, receivedEpochMs: 11_401 }, + start: { occurrences: 1, receivedEpochMs: 10_001 }, + timeline: [ + { method: 'dbGlobalSearch', phase: 'start' }, + { method: 'dbGlobalSearch', phase: 'end' }, + ], + unmatchedCompletions: 0, + }; + return { + afterSettled: sample(1, 1, 121), + afterSettledWindowMs: 500, + externalArtworkCancelled: 42, + ipc, + keyDelayMs: 100, + pid: 4242, + query: QUERY, + sqlAtSentinels: { end: 121, start: 119 }, + queryTrace: [ + { + epochMs: 11_150, + phase: 'start', + resultLength: null, + term: QUERY, + }, + { + epochMs: 11_170, + phase: 'success', + resultLength: 101, + term: null, + }, + ], + renderer, + samples: [ + sample(0, 0, 119), + sample(0, 0, 119), + sample(0, 0, 119), + sample(0, 0, 119), + sample(0, 0, 119), + sample(0, 0, 119), + sample(1, 1, 121), + ], + settle: { + preStartDomMutations: 5, + preStartIpcCalls: 0, + quietMs: 1_000, + sqlStatements: 119, + waitedMs: 1_226, + }, + ...overrides, + }; +} + +test('maps a settled search to exact counters and wall-clock', () => { + const record = toSearchIterationRecord(1, false, measurement()); + assert.deepEqual(record.counters, { + [SEARCH_JOURNEY_COUNTER.CD_TICKS]: 14, + [SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]: 402, + [SEARCH_JOURNEY_COUNTER.IPC_CALLS]: 1, + [SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: 1, + [SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: 0.002, + [SEARCH_JOURNEY_COUNTER.LONG_TASKS]: 0, + [SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: 2, + }); + assert.deepEqual(record.wallClock, { + [SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]: 1_180, + [SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: 692.5, + }); + assert.equal(record.pid, 4242); + assert.equal(record.warmup, false); +}); + +test('breaks every counter down by keystroke', () => { + const record = toSearchIterationRecord(1, false, measurement()); + const perKeystroke = record.evidence['perKeystroke'] as { + cdTicks: number; + domMutations: number; + ipcCalls: number; + key: string; + queryCalls: number; + sqlStatements: number; + }[]; + assert.deepEqual( + perKeystroke.map((entry) => entry.key), + [...QUERY] + ); + assert.deepEqual( + perKeystroke.map((entry) => entry.cdTicks), + [1, 1, 1, 1, 1, 9] + ); + assert.deepEqual( + perKeystroke.map((entry) => entry.queryCalls), + [0, 0, 0, 0, 0, 1] + ); + assert.deepEqual( + perKeystroke.map((entry) => entry.sqlStatements), + [0, 0, 0, 0, 0, 2] + ); + assert.deepEqual( + perKeystroke.map((entry) => entry.domMutations), + [0, 0, 0, 0, 0, 402] + ); + assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], { + count: 0, + windowMs: 500, + }); + assert.deepEqual(record.evidence['ipcTimeline'], [ + '+dbGlobalSearch', + '-dbGlobalSearch', + ]); +}); + +test('a query per keystroke shows up per key, not only in the total', () => { + const record = toSearchIterationRecord( + 1, + false, + measurement({ + samples: [ + sample(0, 0, 100), + sample(0, 0, 100), + sample(1, 1, 102), + sample(2, 2, 104), + sample(3, 3, 106), + sample(4, 4, 108), + sample(5, 5, 110), + ], + settle: { + preStartDomMutations: 5, + preStartIpcCalls: 0, + quietMs: 1_000, + sqlStatements: 100, + waitedMs: 1_000, + }, + sqlAtSentinels: { end: 110, start: 100 }, + ipc: { + ...measurement().ipc, + callsBeforeSentinel: 5, + callsByMethod: { dbGlobalSearch: 5 }, + }, + queryTrace: ['sy', 'sys', 'syst', 'syste', QUERY].flatMap( + (term, position) => [ + { + epochMs: 10_150 + position * 100, + phase: 'start', + resultLength: null, + term, + }, + { + epochMs: 10_170 + position * 100, + phase: 'success', + resultLength: 101, + term: null, + }, + ] + ), + }) + ); + assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 5); + assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 10); + const perKeystroke = record.evidence['perKeystroke'] as { + queryCalls: number; + }[]; + assert.deepEqual( + perKeystroke.map((entry) => entry.queryCalls), + [0, 1, 1, 1, 1, 1] + ); +}); + +test('rejects iterations that did not measure a settled search', () => { + const cases: [Partial, RegExp][] = [ + [{ start: null }, /incomplete-probe/], + [ + { + settle: { ...measurement().renderer.settle, status: 'timeout' }, + }, + /incomplete-probe/, + ], + [ + { keystrokes: measurement().renderer.keystrokes.slice(1) }, + /keystroke-count/, + ], + [ + { + keystrokes: measurement().renderer.keystrokes.map((key) => ({ + ...key, + key: 'x', + })), + }, + /typed-text/, + ], + [ + { settle: { ...measurement().renderer.settle, query: 'syste' } }, + /no-results/, + ], + [{ firstResult: null }, /no-results/], + [ + { settle: { ...measurement().renderer.settle, epochMs: 9_000 } }, + /clock-order/, + ], + [{ preStart: { domMutations: 6, lastMutationEpochMs: 9_990 } }, /dom/], + [ + { + capabilities: { + changeDetectionTicks: 'unavailable-counter-missing', + layoutShift: true, + longTask: true, + }, + }, + /cd-ticks-unavailable-counter-missing/, + ], + ]; + for (const [overrides, error] of cases) { + assert.throws( + () => toSearchIterationRecord(1, false, measurement({}, overrides)), + error + ); + } +}); + +test('rejects work that moved between the quiet snapshot and the first key', () => { + const base = measurement(); + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ ipc: { ...base.ipc, callsBeforeStart: 1 } }) + ), + /activity-before-first-key-ipc/ + ); + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ sqlAtSentinels: { end: 122, start: 120 } }) + ), + /activity-before-first-key-sql/ + ); + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ + samples: [...base.samples.slice(0, 6), sample(2, 1, 121)], + }) + ), + /ipc-sample-mismatch/ + ); +}); + +test('summarizes with the shared summary and nothing unavailable', () => { + const iterations = [0, 1, 2].map((index) => + toSearchIterationRecord( + index, + index === 0, + measurement({ pid: 4_000 + index }) + ) + ); + const entry = summarizeJourneyIterations( + iterations, + SEARCH_JOURNEY_UNAVAILABLE_COUNTERS + ); + assert.equal(entry.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 1); + assert.equal( + entry.counterStability[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]?.stable, + true + ); + assert.equal( + entry.wallClock[ + `${SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED}.p50` + ], + 692.5 + ); + assert.deepEqual(entry.unavailable, {}); +}); + +test('rejects a settle that did not wait for the final query', () => { + const start = (epochMs: number, term: string) => ({ + epochMs, + phase: 'start', + resultLength: null, + term, + }); + const success = (epochMs: number) => ({ + epochMs, + phase: 'success', + resultLength: 101, + term: null, + }); + // Results of an earlier term were shown; the final query never ran + // before the end sentinel. + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ + queryTrace: [ + start(10_500, 'syste'), + success(10_520), + start(11_500, QUERY), + success(11_520), + ], + }) + ), + /final-query-not-run/ + ); + // The final query started but had not completed at the end sentinel. + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ queryTrace: [start(11_390, QUERY)] }) + ), + /final-query-incomplete/ + ); + const record = toSearchIterationRecord(1, false, measurement()); + assert.deepEqual(record.evidence['finalQuery'], { + durationMs: 20, + resultLength: 101, + term: QUERY, + }); +}); + +test('rejects typing slower than the accepted cadence', () => { + const base = measurement(); + const keystrokes = base.renderer.keystrokes.map((key, position) => ({ + ...key, + epochMs: key.epochMs + (position >= 3 ? 200 : 0), + })); + assert.throws( + () => + toSearchIterationRecord(1, false, measurement({}, { keystrokes })), + /typing-cadence: 100, 100, 300, 100, 100/ + ); + assert.deepEqual( + toSearchIterationRecord(1, false, base).evidence['keyIntervalsMs'], + [100, 100, 100, 100, 100] + ); +}); + +test('counts SQL between the sentinels only', () => { + // Background statements after the end sentinel reach the final sample + // but not the counter; they show up after the settle instead. + const record = toSearchIterationRecord( + 1, + false, + measurement({ + afterSettled: sample(1, 1, 126), + samples: [...measurement().samples.slice(0, 6), sample(1, 1, 125)], + }) + ); + assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 2); + assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], { + count: 5, + windowMs: 500, + }); + assert.throws( + () => + toSearchIterationRecord( + 1, + false, + measurement({ sqlAtSentinels: { end: null, start: 119 } }) + ), + /sql-at-sentinels-missing/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/search-journey-record.ts b/apps/electron-backend-e2e/src/performance/search-journey-record.ts new file mode 100644 index 000000000..556f378e8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/search-journey-record.ts @@ -0,0 +1,330 @@ +import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth'; +import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; +import type { JourneyIterationRecord } from './journey-summary'; +import type { SearchJourneyProbeState } from './search-journey-probe'; + +/** + * Maps one measured global search (renderer probe from the first keystroke + * until the results settled, main IPC capture between the start and end + * sentinels, main-process activity sampled before every keystroke) to the + * journey summary's iteration record for J4. + */ +export const SEARCH_JOURNEY_ID = 'search'; + +export const SEARCH_JOURNEY_COUNTER = { + CD_TICKS: 'renderer.cdTicksToResults', + DOM_MUTATIONS: 'renderer.domMutationsToResults', + IPC_CALLS: 'renderer.ipcCallsPerSearch', + IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToResults', + LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore', + LONG_TASKS: 'renderer.longTasks', + SQL_STATEMENTS: 'renderer.sqlStatementsPerSearch', +} as const; + +export const SEARCH_JOURNEY_WALL_CLOCK = { + FIRST_KEYSTROKE_TO_FIRST_RESULT: 'firstKeystrokeToFirstResultMs', + LAST_KEYSTROKE_TO_SETTLED: 'lastKeystrokeToSettledMs', +} as const; + +export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< + Record +> = Object.freeze({}); + +/** Bridge method of a global search query (`DatabaseService`). */ +export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch'; + +/** + * Longest accepted gap between two keydowns. Below the shell's 350 ms input + * debounce, so a late key on a busy machine cannot let an intermediate term + * run a query that steady typing would not. + */ +export const SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS = 250; + +/** One traced query event, stamped in the main process on arrival. */ +export interface SearchJourneyQueryTraceEntry { + readonly epochMs: number; + readonly phase: string; + /** Length of the returned array, on `success`. */ + readonly resultLength: number | null; + readonly term: string | null; +} + +/** + * Main-process activity read in one synchronous pass: the journey capture's + * call counts and the running `main.sqlStatements` total. + */ +export interface SearchJourneyActivitySample { + readonly ipcCalls: number; + readonly queryCalls: number; + readonly sqlStatements: number; +} + +/** How long the app was left alone before the first keystroke. */ +export interface SearchJourneySettle { + readonly preStartDomMutations: number; + readonly preStartIpcCalls: number; + readonly quietMs: number; + readonly sqlStatements: number; + readonly waitedMs: number; +} + +export interface SearchJourneyMeasurement { + readonly externalArtworkCancelled: number; + readonly ipc: JourneyMainIpcCaptureState; + readonly keyDelayMs: number; + readonly pid: number; + readonly query: string; + readonly renderer: SearchJourneyProbeState; + /** + * Before each keystroke (index i before key i + 1), then once after the + * probe settled and once more `afterSettledWindowMs` later. + */ + readonly samples: readonly SearchJourneyActivitySample[]; + readonly afterSettled: SearchJourneyActivitySample; + readonly afterSettledWindowMs: number; + readonly settle: SearchJourneySettle; + /** + * `main.sqlStatements` read in the main process when the start and the + * end sentinel arrived; null when it was not read. + */ + readonly sqlAtSentinels: { + readonly end: number | null; + readonly start: number | null; + }; + /** Every traced query event of the process, in arrival order. */ + readonly queryTrace: readonly SearchJourneyQueryTraceEntry[]; +} + +function roundTenth(value: number): number { + return Math.round(value * 10) / 10; +} + +function roundThousandth(value: number): number { + return Math.round(value * 1_000) / 1_000; +} + +function difference( + samples: readonly SearchJourneyActivitySample[], + index: number, + key: keyof SearchJourneyActivitySample +): number { + return samples[index + 1][key] - samples[index][key]; +} + +/** + * The query the settle waited for: the last one started between the start + * and end sentinels. It must be for the final term and must have completed + * before the end sentinel, otherwise the probe settled on results of an + * earlier term (still shown while the final term debounced). + */ +function finalQuery( + trace: readonly SearchJourneyQueryTraceEntry[], + fromEpochMs: number, + untilEpochMs: number, + query: string +) { + const inWindow = trace.filter( + (entry) => entry.epochMs >= fromEpochMs && entry.epochMs <= untilEpochMs + ); + const starts = inWindow.filter((entry) => entry.phase === 'start'); + const completions = inWindow.filter( + (entry) => entry.phase === 'success' || entry.phase === 'error' + ); + const last = starts.at(-1); + if (last === undefined || last.term !== query) { + throw new Error('search-journey-record-final-query-not-run'); + } + const completion = completions.find( + (entry) => entry.epochMs >= last.epochMs && entry.phase === 'success' + ); + if (completion === undefined || completions.length < starts.length) { + throw new Error('search-journey-record-final-query-incomplete'); + } + return Object.freeze({ + durationMs: completion.epochMs - last.epochMs, + resultLength: completion.resultLength, + term: last.term, + }); +} + +export function toSearchIterationRecord( + index: number, + warmup: boolean, + measurement: SearchJourneyMeasurement +): JourneyIterationRecord { + const { ipc, renderer, samples, settle } = measurement; + const { keystrokes, start } = renderer; + const keys = measurement.query.length; + if (start === null || renderer.settle.status !== 'quiet') { + throw new Error('search-journey-record-incomplete-probe'); + } + if (ipc.start === null) { + throw new Error('search-journey-record-ipc-without-start'); + } + if (keystrokes.length !== keys || samples.length !== keys + 1) { + throw new Error('search-journey-record-keystroke-count'); + } + if (keystrokes.map((key) => key.key).join('') !== measurement.query) { + throw new Error('search-journey-record-typed-text'); + } + if ( + renderer.settle.query !== measurement.query || + renderer.settle.cardCount === 0 || + renderer.firstResult === null + ) { + throw new Error('search-journey-record-no-results'); + } + const settledEpochMs = renderer.settle.epochMs; + const lastKeyEpochMs = keystrokes[keys - 1].epochMs; + if ( + settledEpochMs === null || + settledEpochMs < lastKeyEpochMs || + renderer.firstResult.epochMs < start.epochMs + ) { + throw new Error('search-journey-record-clock-order'); + } + const { end: sqlAtEnd, start: sqlAtStart } = measurement.sqlAtSentinels; + if (sqlAtStart === null || sqlAtEnd === null || sqlAtEnd < sqlAtStart) { + throw new Error('search-journey-record-sql-at-sentinels-missing'); + } + // Activity between the quiet snapshot and the first key could finish + // after it and be counted as the search's. The probe, the capture and + // the SQL total read at the start sentinel all reflect the first + // keydown, so they must still match the snapshot. + const moved = [ + renderer.preStart.domMutations !== settle.preStartDomMutations + ? 'dom' + : null, + ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null, + sqlAtStart !== settle.sqlStatements ? 'sql' : null, + ].filter((kind): kind is string => kind !== null); + if (moved.length > 0) { + throw new Error( + `search-journey-record-activity-before-first-key-${moved.join('-')}` + ); + } + const keyIntervalsMs = keystrokes + .slice(1) + .map((key, position) => + roundTenth(key.epochMs - keystrokes[position].epochMs) + ); + if ( + keyIntervalsMs.some( + (interval) => interval > SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS + ) + ) { + throw new Error( + `search-journey-record-typing-cadence: ${keyIntervalsMs.join(', ')}` + ); + } + if (ipc.sentinel.receivedEpochMs === null) { + throw new Error('search-journey-record-ipc-without-end'); + } + const lastQuery = finalQuery( + measurement.queryTrace, + ipc.start.receivedEpochMs ?? Number.POSITIVE_INFINITY, + ipc.sentinel.receivedEpochMs, + measurement.query + ); + const finalSample = samples[keys]; + if (finalSample.ipcCalls !== ipc.callsBeforeSentinel) { + throw new Error('search-journey-record-ipc-sample-mismatch'); + } + const settleTicks = renderer.settle.ticks; + const ticks = keystrokes.map((key) => key.ticks); + if ( + renderer.capabilities.changeDetectionTicks !== 'counted' || + settleTicks === null || + ticks.some((value) => value === null) + ) { + throw new Error( + `search-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}` + ); + } + const tickAt = (position: number): number => + position < keys ? (ticks[position] as number) : settleTicks; + const serialDepth = computeJourneyIpcSerialDepth(ipc.timeline); + const perKeystroke = keystrokes.map((key, position) => + Object.freeze({ + atMs: roundTenth(key.epochMs - start.epochMs), + cdTicks: tickAt(position + 1) - tickAt(position), + domMutations: renderer.domMutationsByKeystroke[position] ?? 0, + ipcCalls: difference(samples, position, 'ipcCalls'), + key: key.key, + queryCalls: difference(samples, position, 'queryCalls'), + sqlStatements: difference(samples, position, 'sqlStatements'), + }) + ); + return Object.freeze({ + counters: Object.freeze({ + [SEARCH_JOURNEY_COUNTER.CD_TICKS]: settleTicks - tickAt(0), + [SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]: + renderer.counters.domMutations, + [SEARCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel, + [SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: serialDepth.depth, + // Typing is input, so shifts flagged hadRecentInput are + // included, as in J2 and J3. + [SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth( + renderer.counters.layoutShiftScore + + renderer.counters.recentInputLayoutShiftScore + ), + [SEARCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks, + [SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: sqlAtEnd - sqlAtStart, + }), + evidence: Object.freeze({ + capabilities: renderer.capabilities, + epochs: Object.freeze({ + firstKeystroke: start.epochMs, + firstResult: renderer.firstResult.epochMs, + lastKeystroke: lastKeyEpochMs, + mainIpcSentinel: ipc.sentinel.receivedEpochMs, + mainIpcStart: ipc.start.receivedEpochMs, + settleConfirmed: renderer.settle.confirmedEpochMs, + settled: settledEpochMs, + }), + externalArtworkCancelled: measurement.externalArtworkCancelled, + firstResult: Object.freeze({ + cardCount: renderer.firstResult.cardCount, + query: renderer.firstResult.query, + }), + ipcCallsAfterSettled: ipc.callsAfterSentinel, + ipcCallsByMethod: ipc.callsByMethod, + ipcSerialDepth: serialDepth, + ipcTimeline: ipc.timeline.map( + (event) => + `${event.phase === 'start' ? '+' : '-'}${event.method}` + ), + finalQuery: lastQuery, + keyDelayMs: measurement.keyDelayMs, + keyIntervalsMs, + layoutShift: Object.freeze({ + recentInput: roundThousandth( + renderer.counters.recentInputLayoutShiftScore + ), + withoutRecentInput: roundThousandth( + renderer.counters.layoutShiftScore + ), + }), + longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth), + perKeystroke, + query: measurement.query, + results: Object.freeze({ cardCount: renderer.settle.cardCount }), + settle, + sqlAtSentinels: measurement.sqlAtSentinels, + sqlStatementsAfterSettled: Object.freeze({ + count: measurement.afterSettled.sqlStatements - sqlAtEnd, + windowMs: measurement.afterSettledWindowMs, + }), + }), + index, + pid: measurement.pid, + wallClock: Object.freeze({ + [SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]: + roundTenth(renderer.firstResult.epochMs - start.epochMs), + [SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: roundTenth( + settledEpochMs - lastKeyEpochMs + ), + }), + warmup, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts index f9a67c9d7..3137c0f1f 100644 --- a/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts +++ b/apps/electron-backend-e2e/src/performance/zoneless-migration.spec.ts @@ -70,9 +70,21 @@ function readEagerChecklist(): { open: string[]; done: string[] } { const sources = readSources(); +// Component metadata only: a comment or string that names the strategy is +// not an Eager component. +const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/; + +function withoutComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); +} + +function isEagerComponent(text: string): boolean { + return eagerMetadata.test(withoutComments(text)); +} + test('the zoneless checklist lists exactly the components that are still Eager', () => { const eager = [...sources] - .filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager')) + .filter(([, text]) => isEagerComponent(text)) .map(([file]) => file) .sort(); const { open } = readEagerChecklist(); @@ -106,6 +118,23 @@ test('the guard skips test-only file names and keeps production ones', () => { } }); +test('a comment that names the Eager strategy is not an Eager component', () => { + assert.equal( + isEagerComponent( + '// was ChangeDetectionStrategy.Eager before C6\n' + + '/* changeDetection: ChangeDetectionStrategy.Eager */\n' + + '@Component({ changeDetection: ChangeDetectionStrategy.OnPush })' + ), + false + ); + assert.equal( + isEagerComponent( + '@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})' + ), + true + ); +}); + test('ticked checklist entries name files that exist', () => { for (const file of readEagerChecklist().done) { assert.ok(sources.has(file), `${file} is ticked but does not exist`); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index c313acb6e..56f95b84f 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -371,7 +371,7 @@ test.describe('Electron Recently Viewed', () => { } }); - test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({ + test('opens Xtream recent movies and series inline and preserves history through the movie portal handoff', async ({ dataDir, request, }) => { @@ -405,6 +405,7 @@ test.describe('Electron Recently Viewed', () => { await startAndConfirmPlayback(app.mainWindow, () => playCurrentDetail(app.mainWindow) ); + const moviePath = new URL(app.mainWindow.url()).pathname; await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -435,6 +436,42 @@ test.describe('Electron Recently Viewed', () => { playlistTitle: portalTitle, }); + const viewInPortal = app.mainWindow.getByRole('button', { + name: 'View in portal', + exact: true, + }); + await viewInPortal.click(); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toBe(moviePath); + await expect( + app.mainWindow.locator('app-content-hero') + ).toContainText(movieTitle); + await expect(viewInPortal).toHaveCount(0); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); + + await goBackFromDetail(app.mainWindow); + await expectInlineCollectionDetail(app.mainWindow, { + pathname: /\/workspace\/global-recent$/, + title: movieTitle, + playlistTitle: portalTitle, + }); + await app.mainWindow.goForward(); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toBe(moviePath); + await expect( + app.mainWindow.locator('app-content-hero') + ).toContainText(movieTitle); + await app.mainWindow.goBack(); + await expectInlineCollectionDetail(app.mainWindow, { + pathname: /\/workspace\/global-recent$/, + title: movieTitle, + playlistTitle: portalTitle, + }); + await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts index da90fec62..f60b844fb 100644 --- a/apps/electron-backend-e2e/src/window-controls.e2e.ts +++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts @@ -1,8 +1,14 @@ +import type { Page } from '@playwright/test'; import { + addXtreamPortal, + clickFirstGridListCard, closeElectronApp, expect, launchElectronApp, + LaunchedElectronApp, + resetMockServers, test, + waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; // Custom window controls are only rendered on Windows/Linux; macOS keeps @@ -188,6 +194,93 @@ test.describe('Custom window controls', () => { }); }); +// The native buttons are 14pt circles. At 100 % zoom the first header +// control starts 60pt right of their origin, where macOS 26 ends them +// (earlier releases end them at 52pt). +const lightsHeight = 14; +const headerControlOffset = 60; + +/** Where macOS drew the native window buttons, in window points. */ +async function trafficLights( + app: LaunchedElectronApp +): Promise<{ x: number; y: number }> { + const lights = await app.electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition() + ); + expect(lights, 'native window button position').toBeTruthy(); + return lights ?? { x: Number.NaN, y: Number.NaN }; +} + +/** Window pixels per CSS pixel. */ +function zoomFactor(page: Page): Promise { + return page.evaluate(() => window.outerWidth / window.innerWidth); +} + +/** + * Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels + * but not the native buttons. + */ +async function zoomOutFully(page: Page): Promise { + for (let step = 0; step < 8; step++) { + await page.evaluate(() => window.electron.adjustZoomLevel('out')); + } + await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6); +} + +async function resetZoom(page: Page): Promise { + await page.evaluate(() => window.electron.adjustZoomLevel('reset')); + await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2); +} + +/** + * The header's first rendered control and the top of the content area, in + * window pixels (CSS pixels times the zoom factor). + */ +function headerLayout( + page: Page +): Promise<{ control?: string; left: number; contentTop: number }> { + return page.locator('.workspace-header').evaluate((header) => { + const zoom = window.outerWidth / window.innerWidth; + const first = [...header.children].find( + (child) => child.getBoundingClientRect().width > 0 + ); + const body = document.querySelector('.workspace-body'); + return { + control: + first?.getAttribute('data-test-id') ?? + first?.tagName.toLowerCase(), + left: (first?.getBoundingClientRect().left ?? 0) * zoom, + contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom, + }; + }); +} + +/** + * The first header control starts right of the lights and the content area + * below them. Polled: the layout follows a zoom change after its resize. + */ +async function expectHeaderClearOfLights( + page: Page, + lights: { x: number; y: number }, + control: string, + label: string +): Promise { + const layout = () => headerLayout(page); + await expect + .poll(async () => (await layout()).control, { message: label }) + .toBe(control); + await expect + .poll(async () => (await layout()).left, { + message: `${label}: first control`, + }) + .toBeGreaterThanOrEqual(lights.x + headerControlOffset); + await expect + .poll(async () => (await layout()).contentTop, { + message: `${label}: content top`, + }) + .toBeGreaterThanOrEqual(lights.y + lightsHeight); +} + test.describe('macOS traffic lights', () => { test.skip( process.platform !== 'darwin', @@ -205,49 +298,88 @@ test.describe('macOS traffic lights', () => { const firstLink = page.locator('.app-rail a').first(); await expect(firstLink).toBeVisible(); - const [linkBox, contentBox] = await Promise.all([ - firstLink.boundingBox(), - page.locator('.workspace-content').boundingBox(), - ]); // Aligned with the content area, where the dashboard hero starts. - expect( - Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100)) - ).toBeLessThanOrEqual(1); + const linkOffsetFromContent = async (): Promise => { + const [linkBox, contentBox] = await Promise.all([ + firstLink.boundingBox(), + page.locator('.workspace-content').boundingBox(), + ]); + return Math.abs( + (linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN) + ); + }; + expect(await linkOffsetFromContent()).toBeLessThanOrEqual(1); - const lights = await app.electronApp.evaluate(({ BrowserWindow }) => - BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition() - ); - expect(lights, 'native window button position').toBeTruthy(); - const lightsY = lights?.y ?? Number.NaN; - // The buttons are about 14pt tall; keep a visible gap below them, - // measured in window pixels (CSS pixels times the zoom factor). + const lights = await trafficLights(app); + // Keep a visible gap below the buttons, measured in window pixels + // (CSS pixels times the zoom factor). const linkTopInWindowPixels = async (): Promise => { const [box, zoom] = await Promise.all([ firstLink.boundingBox(), - page.evaluate(() => window.outerWidth / window.innerWidth), + zoomFactor(page), ]); return (box?.y ?? Number.NaN) * zoom; }; expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual( - lightsY + 14 + 16 + lights.y + lightsHeight + 16 ); - // App zoom scales CSS pixels but not the native buttons: at the - // smallest zoom the inset must still clear them. - for (let step = 0; step < 8; step++) { - await page.evaluate(() => - window.electron.adjustZoomLevel('out') - ); - } - await expect - .poll(() => - page.evaluate(() => window.outerWidth / window.innerWidth) - ) - .toBeLessThan(0.6); + // At the smallest zoom the inset must still clear the buttons, + // and the link still starts with the content area. + await zoomOutFully(page); await expect .poll(linkTopInWindowPixels) - .toBeGreaterThanOrEqual(lightsY + 14 + 8); - await page.evaluate(() => window.electron.adjustZoomLevel('reset')); + .toBeGreaterThanOrEqual(lights.y + lightsHeight + 8); + await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1); + await resetZoom(page); + } finally { + await closeElectronApp(app); + } + }); + + test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const lights = await trafficLights(app); + const switcher = 'app-playlist-switcher'; + const back = 'workspace-header-back'; + + // The first page has no history to go back to: the switcher leads. + await expectHeaderClearOfLights(page, lights, switcher, 'start'); + await zoomOutFully(page); + await expectHeaderClearOfLights( + page, + lights, + switcher, + 'start at min zoom' + ); + await resetZoom(page); + + // A detail page puts its Back first, pulled 8px toward the edge. + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await page + .getByRole('link', { name: 'Series', exact: true }) + .click(); + await clickFirstGridListCard(page); + await expect(page.getByTestId(back)).toBeVisible({ + timeout: 20_000, + }); + await expectHeaderClearOfLights(page, lights, back, 'detail'); + await zoomOutFully(page); + await expectHeaderClearOfLights( + page, + lights, + back, + 'detail at min zoom' + ); + await resetZoom(page); } finally { await closeElectronApp(app); } diff --git a/apps/electron-backend-e2e/src/window-visibility.e2e.ts b/apps/electron-backend-e2e/src/window-visibility.e2e.ts index ae323c541..777101efd 100644 --- a/apps/electron-backend-e2e/src/window-visibility.e2e.ts +++ b/apps/electron-backend-e2e/src/window-visibility.e2e.ts @@ -27,8 +27,9 @@ const setWindowState = ( ); /** - * The app creates its window with `show: false` and shows it on - * `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`. + * The app creates its window with `show: false` and shows it at + * `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()` + * sent earlier would be undone by that `show()`. */ async function waitUntilShown(app: UnautomatedElectronApp): Promise { await expect diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 7e597144d..f8f12e5bd 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -75,11 +75,14 @@ type MockMainWindow = { maximize: jest.Mock; on: jest.Mock void]>; once: jest.Mock void]>; + removeListener: jest.Mock void]>; setFullScreen: jest.Mock; setMenu: jest.Mock; show: jest.Mock; webContents: { on: jest.Mock void]>; + once: jest.Mock void]>; + removeListener: jest.Mock void]>; openDevTools: jest.Mock; setWindowOpenHandler: jest.Mock; getZoomLevel: jest.Mock; @@ -98,12 +101,18 @@ function createMockMainWindow(): MockMainWindow { maximize: jest.fn(), on: jest.fn void]>(), once: jest.fn void]>(), + removeListener: jest.fn void]>(), isDestroyed: jest.fn().mockReturnValue(false), setFullScreen: jest.fn(), setMenu: jest.fn(), show: jest.fn(), webContents: { on: jest.fn void]>(), + once: jest.fn void]>(), + removeListener: jest.fn< + void, + [string, (...args: unknown[]) => void] + >(), openDevTools: jest.fn(), setWindowOpenHandler: jest.fn(), getZoomLevel: jest.fn().mockReturnValue(0), @@ -366,6 +375,30 @@ describe('Electron app security helpers', () => { expect(mainWindow.show).toHaveBeenCalledTimes(1); }); + it('shows the window at did-finish-load when ready-to-show has not come yet', () => { + storeStartupWindowMode('maximized'); + const mainWindow = createWindowViaOnReady(); + + expect(BrowserWindow).toHaveBeenCalledWith( + expect.objectContaining({ + show: false, + backgroundColor: '#1f1f23', + }) + ); + const [loadHandler] = mainWindow.webContents.once.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler); + loadHandler(); + + expect(mainWindow.maximize).toHaveBeenCalledTimes(1); + expect(mainWindow.show).toHaveBeenCalledTimes(1); + + // The later ready-to-show is a no-op. + fireReadyToShow(mainWindow); + expect(mainWindow.show).toHaveBeenCalledTimes(1); + expect(mainWindow.maximize).toHaveBeenCalledTimes(1); + }); + it('creates the window fullscreen when the stored mode says so', () => { storeStartupWindowMode('fullscreen'); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 09b927e6c..e60b010c1 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, Menu, screen, session, shell } from 'electron'; import { ElectronBridgeWindowState, + MACOS_TRAFFIC_LIGHTS_POSITION, WINDOW_STATE_CHANGED, } from '@iptvnator/shared/interfaces'; import { join, resolve } from 'path'; @@ -15,6 +16,10 @@ import { trace, traceStartupPhase, } from './services/debug-trace'; +import { + MAIN_WINDOW_BACKGROUND_COLOR, + showMainWindowWhenLoaded, +} from './services/main-window-first-show'; import { attachMainWindowPerformanceCounters } from './services/performance-counters'; import { STARTUP_WINDOW_MODE, @@ -397,7 +402,7 @@ export default class App { return { titleBarStyle: 'hidden', titleBarOverlay: true, - trafficLightPosition: { x: 16, y: 20 }, + trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION }, }; } @@ -512,6 +517,9 @@ export default class App { width: width, height: height, show: false, + // The splash colour: the window can be shown before its first + // paint (main-window-first-show.ts). + backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR, webPreferences: getMainWindowWebPreferences(), ...savedWindowBounds, // Fullscreen is a constructor option: the window is created @@ -543,15 +551,17 @@ export default class App { App.mainWindow.center(); } - // if main window is ready to show, close the splash window and show the main window - App.mainWindow.once('ready-to-show', () => { + // Shown at ready-to-show or did-finish-load, whichever comes first + // (see main-window-first-show.ts). + const mainWindow = App.mainWindow; + showMainWindowWhenLoaded(mainWindow, () => { // maximize() on a hidden window shows it (Electron docs), so it - // has to wait for ready-to-show like show() does — any earlier - // and a blank window flashes before the renderer paints. + // waits for the document like show() does — any earlier and a + // blank window flashes before the splash is there. if (startupWindowMode === 'maximized') { - App.mainWindow.maximize(); + mainWindow.maximize(); } - App.mainWindow.show(); + mainWindow.show(); // macOS ignores the constructor's `fullscreen` while the window // is hidden — an NSWindow can only toggle fullscreen once it is // on screen — so the request is repeated after show() wherever @@ -562,9 +572,9 @@ export default class App { // asking for it again. if ( startupWindowMode === 'fullscreen' && - !App.mainWindow.isFullScreen() + !mainWindow.isFullScreen() ) { - requestFullScreen(App.mainWindow, true); + requestFullScreen(mainWindow, true); } }); diff --git a/apps/electron-backend/src/app/services/main-window-first-show.spec.ts b/apps/electron-backend/src/app/services/main-window-first-show.spec.ts new file mode 100644 index 000000000..9ce24d815 --- /dev/null +++ b/apps/electron-backend/src/app/services/main-window-first-show.spec.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'events'; +import { + type FirstShowWindow, + showMainWindowWhenLoaded, +} from './main-window-first-show'; + +function createWindow(): FirstShowWindow & + EventEmitter & { + webContents: EventEmitter; + destroyed: boolean; + } { + const window = Object.assign(new EventEmitter(), { + destroyed: false, + webContents: new EventEmitter(), + isDestroyed(): boolean { + return window.destroyed; + }, + }); + return window; +} + +describe('showMainWindowWhenLoaded', () => { + it('shows the window at did-finish-load when ready-to-show has not fired', () => { + // The Linux race: the hidden window gets no frame for its first + // paint, so ready-to-show (and the splash's animation frame) would + // wait about a second after the document has loaded. + const window = createWindow(); + const show = jest.fn(); + showMainWindowWhenLoaded(window, show); + + window.webContents.emit('did-finish-load'); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('shows the window at ready-to-show when that comes first', () => { + const window = createWindow(); + const show = jest.fn(); + showMainWindowWhenLoaded(window, show); + + window.emit('ready-to-show'); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('shows the window only once and detaches the other listener', () => { + const window = createWindow(); + const show = jest.fn(); + showMainWindowWhenLoaded(window, show); + + window.webContents.emit('did-finish-load'); + window.emit('ready-to-show'); + // A reload loads the document again; the window is already shown. + window.webContents.emit('did-finish-load'); + + expect(show).toHaveBeenCalledTimes(1); + expect(window.listenerCount('ready-to-show')).toBe(0); + expect(window.webContents.listenerCount('did-finish-load')).toBe(0); + }); + + it('does not show a window that was destroyed before it loaded', () => { + const window = createWindow(); + const show = jest.fn(); + showMainWindowWhenLoaded(window, show); + + window.destroyed = true; + window.emit('ready-to-show'); + + expect(show).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/main-window-first-show.ts b/apps/electron-backend/src/app/services/main-window-first-show.ts new file mode 100644 index 000000000..b7c0ff38d --- /dev/null +++ b/apps/electron-backend/src/app/services/main-window-first-show.ts @@ -0,0 +1,52 @@ +/** + * When the hidden main window is first shown. + * + * The window is created with `show: false` and used to be shown on + * `ready-to-show` only. That event needs the window's first visually + * non-empty paint, and a hidden window does not always get a frame for it: + * on Linux under X11, when the startup scripts run before that frame, the + * next one comes about a second later. Until then nothing is on screen and + * the renderer gets no animation frames, so the splash that `main.ts` + * removes in a `requestAnimationFrame` stays even after the dashboard has + * rendered (J1 on the CI runner: about 450 ms later to the first card, in + * roughly one launch out of three, see docs/architecture/performance-journeys.md). + * + * The window is therefore shown at whichever comes first: `ready-to-show` + * or the main frame's `did-finish-load`. At `did-finish-load` the inline + * splash is parsed and styled, and the window's `backgroundColor` matches + * it, so showing before the first paint does not flash. + */ + +/** Matches `#initial-splash` in `apps/web/src/index.html`. */ +export const MAIN_WINDOW_BACKGROUND_COLOR = '#1f1f23'; + +type OnceEmitter = { + once(event: string, listener: () => void): unknown; + removeListener(event: string, listener: () => void): unknown; +}; + +export type FirstShowWindow = OnceEmitter & { + isDestroyed(): boolean; + readonly webContents: OnceEmitter; +}; + +/** Calls `show` once, at `ready-to-show` or `did-finish-load`, whichever comes first. */ +export function showMainWindowWhenLoaded( + window: FirstShowWindow, + show: () => void +): void { + let shown = false; + const showOnce = (): void => { + if (shown) { + return; + } + shown = true; + window.removeListener('ready-to-show', showOnce); + window.webContents.removeListener('did-finish-load', showOnce); + if (!window.isDestroyed()) { + show(); + } + }; + window.once('ready-to-show', showOnce); + window.webContents.once('did-finish-load', showOnce); +} diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index b40e6fb61..e440d0cd9 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -49,6 +49,24 @@ test.describe('Settings', () => { await page.waitForURL(/\/workspace\/dashboard$/); }); + test('@settings @web settings opening the session lead to the dashboard', async ({ + page, + }) => { + // The only entry of a new tab's history, as in Electron after a + // deep link: browser Back has nowhere to go. + const firstEntryPage = await page.context().newPage(); + await firstEntryPage.goto('/workspace/settings/general'); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator('.settings-container')).toBeVisible( + { timeout: 15_000 } + ); + + await settingsBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(/\/workspace\/dashboard$/); + // The dashboard replaced the settings entry: no Back leads to it. + await expect(settingsBack(firstEntryPage)).toHaveCount(0); + }); + test('@settings @web Change video player', async ({ page }) => { await openSettings(page); await openSettingsSection(page, 'playback'); diff --git a/apps/web-e2e/src/workspace-header-back.e2e.ts b/apps/web-e2e/src/workspace-header-back.e2e.ts index 6bdb5f175..a763ff4eb 100644 --- a/apps/web-e2e/src/workspace-header-back.e2e.ts +++ b/apps/web-e2e/src/workspace-header-back.e2e.ts @@ -8,8 +8,10 @@ import { /** * Pages reached from a detail or the header search draw no Back arrow of - * their own: they register the workspace header's leading Back, which keeps - * their previous return behaviour (history Back). + * their own: they register the workspace header's leading Back. It goes back + * in history when the page was reached in the app; a page that opened the + * session (a deep link, reload or restored view) leads to its parent + * instead, replacing its own history entry. * Contract: docs/architecture/workspace-shell.md, "Header Back". */ @@ -29,6 +31,24 @@ async function expectOnlyHeaderBack(page: Page): Promise { await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts'); } +/** The portal's own URL, from the VOD list that adding it opens. */ +function portalUrlOf(page: Page): string { + return page.url().replace(/\/vod.*$/, ''); +} + +/** + * Opens `url` as the only entry of a new tab's history, where browser Back + * has nowhere to go, as in Electron after a deep link or restored view. A + * `page.goto` in the same tab would leave the previous document behind, + * often at the very URL of the parent. + */ +async function openAsFirstEntry(page: Page, url: string): Promise { + const firstEntryPage = await page.context().newPage(); + await interceptXtreamRequests(firstEntryPage); + await firstEntryPage.goto(url); + return firstEntryPage; +} + test.beforeEach(async ({ page, request }) => { await request.post(`${MOCK_SERVER}/reset`); await page.goto('/'); @@ -36,7 +56,7 @@ test.beforeEach(async ({ page, request }) => { await addXtreamPortal(page); }); -test('@web @xtream the in-portal search page returns through the header Back', async ({ +test('@web @xtream the in-portal search page reached in the app goes back in history', async ({ page, }) => { await page @@ -62,25 +82,51 @@ test('@web @xtream the in-portal search page returns through the header Back', a await page.waitForURL(/\/workspace\/dashboard$/); }); -for (const { name, path, selector } of [ +for (const { name, path, selector, parent } of [ { - name: 'Discover', + name: 'movie Discover', path: 'discover?type=movie&genre=18&genreLabel=Drama', selector: 'app-discover-view', + parent: 'vod', + }, + { + name: 'series Discover', + path: 'discover?type=tv&genre=18&genreLabel=Drama', + selector: 'app-discover-view', + parent: 'series', + }, + // The portal root: its route config redirects to the default section. + { + name: 'actor', + path: 'actor/287', + selector: 'app-actor-view', + parent: 'vod', + }, + { + name: 'search', + path: 'search?q=Movie', + selector: 'app-search-layout', + parent: 'vod', }, - { name: 'actor', path: 'actor/287', selector: 'app-actor-view' }, ]) { - test(`@web @xtream the ${name} page returns through the header Back`, async ({ + test(`@web @xtream the ${name} page opening the session leads to its parent`, async ({ page, }) => { - const listUrl = page.url(); - const portalUrl = listUrl.replace(/\/vod.*$/, ''); + const portalUrl = portalUrlOf(page); + const firstEntryPage = await openAsFirstEntry( + page, + `${portalUrl}/${path}` + ); + // A cold start in a new tab passes the startup splash first. + await expect(firstEntryPage.locator(selector)).toBeAttached({ + timeout: 15_000, + }); - await page.goto(`${portalUrl}/${path}`); - await expect(page.locator(selector)).toBeAttached(); - - await expectOnlyHeaderBack(page); - await headerBack(page).click(); - await page.waitForURL(listUrl); + await expectOnlyHeaderBack(firstEntryPage); + await headerBack(firstEntryPage).click(); + await firstEntryPage.waitForURL(`${portalUrl}/${parent}`); + // The parent replaced the page's entry, so nothing precedes it and + // the list shows no Back (the history fallback would otherwise). + await expect(headerBack(firstEntryPage)).toHaveCount(0); }); } diff --git a/apps/web/project.json b/apps/web/project.json index 7244c4ac5..74c415311 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -143,12 +143,50 @@ } ] }, + "electron-performance-zoneless": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.performance.ts" + }, + { + "replace": "apps/web/src/environments/change-detection.providers.ts", + "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, "optimization": false, "extractLicenses": false, "sourceMap": true + }, + "electron-e2e-zoneless": { + "baseHref": "./", + "serviceWorker": false, + "optimization": false, + "extractLicenses": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/change-detection.providers.ts", + "with": "apps/web/src/environments/change-detection.providers.zoneless.ts" + } + ] } }, "defaultConfiguration": "production" diff --git a/apps/web/src/app/app-update-notification-panel.component.ts b/apps/web/src/app/app-update-notification-panel.component.ts index a99e814ad..91afea93b 100644 --- a/apps/web/src/app/app-update-notification-panel.component.ts +++ b/apps/web/src/app/app-update-notification-panel.component.ts @@ -101,8 +101,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service'; } `, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .app-update-notification { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 15c3df9c3..804899aa4 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -55,8 +55,7 @@ const debugAppComponent = createDevLogger('AppComponent'); @Component({ selector: 'app-root', templateUrl: './app.component.html', - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AppStartupStatusComponent, AppUpdateNotificationPanelComponent, diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 1fc0d8950..edd657996 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -8,12 +8,7 @@ import { FullscreenOverlayContainer, OverlayContainer, } from '@angular/cdk/overlay'; -import { - ApplicationConfig, - inject, - importProvidersFrom, - provideZoneChangeDetection, -} from '@angular/core'; +import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; @@ -39,6 +34,7 @@ import { } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; +import { changeDetectionProviders } from '../environments/change-detection.providers'; import { routes } from './app.routes'; import { ElectronService } from './services/electron.service'; import { ExternalPlaybackService } from './services/external-playback.service'; @@ -110,7 +106,7 @@ export function DataFactory() { export const appConfig: ApplicationConfig = { providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), + ...changeDetectionProviders, provideRouter(routes, withComponentInputBinding()), provideAnimations(), // CDK overlays (menus, tooltips, dialogs) live in a container under diff --git a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts index 870e1c142..afacf4eff 100644 --- a/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts +++ b/apps/web/src/app/settings/app-update-release-notes-dialog.component.ts @@ -178,8 +178,7 @@ function decorateReleaseNotesHtml(html: string): string { `, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .release-notes-dialog { diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 9d6f1f6a0..028522a27 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -19,6 +19,7 @@ import { ElectronBridgeAppUpdateStatus, } from '@iptvnator/shared/interfaces'; import { UpdateChannelOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-about-section', @@ -32,8 +33,7 @@ import { UpdateChannelOption } from './settings.models'; ], templateUrl: './settings-about-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ':host { display: contents; }', '.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }', @@ -58,6 +58,13 @@ export class SettingsAboutSectionComponent { * setting. Absent in hosts that only render the version block. */ readonly form = input(null); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly updateChannelOptions = input([]); readonly buildCommitShort = computed(() => { diff --git a/apps/web/src/app/settings/settings-backup-section.component.ts b/apps/web/src/app/settings/settings-backup-section.component.ts index f383c6163..0af8aefba 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.ts +++ b/apps/web/src/app/settings/settings-backup-section.component.ts @@ -20,8 +20,7 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-backup-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsBackupSectionComponent { diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.ts b/apps/web/src/app/settings/settings-dashboard-section.component.ts index 3cb1739b6..c39bdbca5 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.ts +++ b/apps/web/src/app/settings/settings-dashboard-section.component.ts @@ -9,6 +9,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-dashboard-section', @@ -21,10 +22,15 @@ import { TranslateModule } from '@ngx-translate/core'; ], templateUrl: './settings-dashboard-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsDashboardSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } } diff --git a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts index 87dd57aa2..9f4a0fea2 100644 --- a/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts +++ b/apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts @@ -31,8 +31,7 @@ type SettingsDeleteSummaryItem = { selector: 'app-settings-delete-all-playlists-dialog', templateUrl: './settings-delete-all-playlists-dialog.component.html', styleUrls: ['./settings-delete-all-playlists-dialog.component.scss'], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, MatButtonModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index 8f07d4124..8152b24da 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -17,6 +17,7 @@ import { EpgViewMode } from '@iptvnator/shared/interfaces'; import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; import { EpgViewModeOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-epg-section', @@ -34,12 +35,18 @@ import { EpgViewModeOption } from './settings.models'; ], templateUrl: './settings-epg-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsEpgSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly epgUrl = input.required(); readonly isClearingEpgData = input(false); readonly canBrowseFiles = input(false); diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index b24d2afbc..b44971bf0 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -19,6 +19,7 @@ import { StartupWindowModeOption, ThemeOption, } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-general-section', @@ -33,12 +34,18 @@ import { ], templateUrl: './settings-general-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsGeneralSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly languageEnum = input.required(); readonly themeOptions = input.required(); readonly coverSizeOptions = input.required(); diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 6b0afd01f..5a3eee85d 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -20,6 +20,7 @@ import { reportsPlaybackFailures, } from '@iptvnator/shared/interfaces'; import { SettingsPlayerOption } from './settings.models'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-playback-section', @@ -36,8 +37,7 @@ import { SettingsPlayerOption } from './settings.models'; ], templateUrl: './settings-playback-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsPlaybackSectionComponent { @@ -53,6 +53,13 @@ export class SettingsPlaybackSectionComponent { ].join('\n'); readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly players = input.required(); readonly streamFormatEnum = input.required(); readonly isDesktop = input(false); diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 37b6408ef..7d667f46a 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-remote-control-section', @@ -30,12 +31,18 @@ import { QRCodeComponent } from 'angularx-qrcode'; ], templateUrl: './settings-remote-control-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsRemoteControlSectionComponent { readonly form = input.required(); + + constructor() { + // Parent patches (Discard, backup import) change the form outside + // this OnPush section's events. + markSectionForCheckOnFormEvents(this.form); + } + readonly localIpAddresses = input.required(); readonly visibleQrCodeIp = input(null); diff --git a/apps/web/src/app/settings/settings-reset-section.component.ts b/apps/web/src/app/settings/settings-reset-section.component.ts index ae6d5226f..8396ee5c4 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.ts +++ b/apps/web/src/app/settings/settings-reset-section.component.ts @@ -22,8 +22,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models'; ], templateUrl: './settings-reset-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [':host { display: contents; }'], }) export class SettingsResetSectionComponent { diff --git a/apps/web/src/app/settings/settings-section-form-render.ts b/apps/web/src/app/settings/settings-section-form-render.ts new file mode 100644 index 000000000..f0c19729b --- /dev/null +++ b/apps/web/src/app/settings/settings-section-form-render.ts @@ -0,0 +1,30 @@ +import { ChangeDetectorRef, inject, type Signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import type { AbstractControl } from '@angular/forms'; +import { EMPTY, switchMap } from 'rxjs'; + +/** + * Marks an OnPush settings section for check on every event of its form. + * + * The sections read form values and states in their templates (selected + * theme, `epgField.value`, `form().value.player`), which are not signals. + * The parent changes the form outside the section's template events: Discard + * and backup import patch it, the store hydrates it, and the EPG file picker + * sets a control after an `await`. Without this the section keeps showing + * the previous value until some unrelated event marks it. `events` covers + * value, status, touched and pristine changes, including those of child + * controls, which bubble up to the group. + * + * Call it from a field initializer or the constructor. + */ +export function markSectionForCheckOnFormEvents( + form: Signal +): void { + const changeDetector = inject(ChangeDetectorRef); + toObservable(form) + .pipe( + switchMap((control) => control?.events ?? EMPTY), + takeUntilDestroyed() + ) + .subscribe(() => changeDetector.markForCheck()); +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 4b57acfba..0959308c7 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -16,6 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; +import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -33,8 +34,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; ], templateUrl: './settings-tmdb-section.component.html', encapsulation: ViewEncapsulation.None, - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` app-settings-tmdb-section { @@ -92,6 +92,7 @@ export class SettingsTmdbSectionComponent { readonly isClearing = signal(false); constructor() { + markSectionForCheckOnFormEvents(this.form); // Sizing the cache is a full table scan, but this component only // exists while its section page is open, so loading on construction // preserves the old "wait until the user is actually looking" diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts index c556f7d1b..c709a9e06 100644 --- a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts @@ -54,8 +54,7 @@ export interface SettingsUnsavedChangesDialogData { } `, ], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: `

{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }} diff --git a/apps/web/src/app/settings/settings.component.form.spec.ts b/apps/web/src/app/settings/settings.component.form.spec.ts index 5cd4b2d3d..bf9b9b1d2 100644 --- a/apps/web/src/app/settings/settings.component.form.spec.ts +++ b/apps/web/src/app/settings/settings.component.form.spec.ts @@ -1,3 +1,4 @@ +import { FormArray, FormControl } from '@angular/forms'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; @@ -143,6 +144,25 @@ describe('SettingsComponent form', () => { }); }); + // The sections are OnPush and a Discard or backup import patches the + // form outside their template events, so the section must mark + // itself on the form's events. The fixture renders on its own here: + // a forced detectChanges() would hide a section that is not marked. + it('re-renders section selections after a value-only form patch', async () => { + const darkTheme = () => + (fixture.nativeElement as HTMLElement).querySelector( + '[data-test-id="DARK_THEME"]' + ); + fixture.autoDetectChanges(); + await fixture.whenStable(); + expect(darkTheme()?.getAttribute('aria-checked')).toBe('false'); + + component.settingsForm.patchValue({ theme: Theme.DarkTheme }); + await fixture.whenStable(); + + expect(darkTheme()?.getAttribute('aria-checked')).toBe('true'); + }); + it('hydrates a shared web controls opt-out from the settings store', () => { settingsStore._setSettings({ webPlayerSharedControls: false, @@ -185,6 +205,26 @@ describe('SettingsComponent form', () => { expect(settingsStore.updateSettings).not.toHaveBeenCalled(); }); + // The native file picker sets the EPG control after an await, with + // no template event in the OnPush section; its status must follow. + it('shows the source status after a control is set outside the section', async () => { + setSettingsSection('epg'); + fixture.autoDetectChanges(); + const epgUrls = component.settingsForm.get('epgUrl') as FormArray; + epgUrls.push(new FormControl('')); + await fixture.whenStable(); + const status = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'app-epg-source-status' + ); + expect(status()).toBeNull(); + + epgUrls.at(epgUrls.length - 1).setValue('/tmp/guide.xml'); + await fixture.whenStable(); + + expect(status()).not.toBeNull(); + }); + it('stages the EPG view mode without writing to the store until Save', () => { setSettingsSection('epg'); fixture.detectChanges(); @@ -314,6 +354,29 @@ describe('SettingsComponent form', () => { expect(unsavedBar()).toBeNull(); }); + // The page owns the bar and is OnPush, and Save marks the form + // pristine after an async store write, also on a page without a form + // section. `pristine` and `valid` read the form's state signals, so + // the page re-renders without a form subscription; no forced render + // here, so a regression shows. + it('hides after a save on a page without a form section', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + setSettingsSection('backup'); + fixture.autoDetectChanges(); + component.settingsForm.get('theme')?.setValue(Theme.DarkTheme); + component.settingsForm.markAsDirty(); + await fixture.whenStable(); + expect(unsavedBar()).not.toBeNull(); + + component.onSubmit(); + await fixture.whenStable(); + // The render the form event scheduled runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + + expect(component.settingsForm.pristine).toBe(true); + expect(unsavedBar()).toBeNull(); + }); + it('discard reverts a staged cover size (regression: eager persist made it stick)', () => { const largeCoverButton = ( fixture.nativeElement as HTMLElement diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5f95e8b07..f43b3eb30 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -102,8 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsSearchResultsComponent, SettingsTmdbSectionComponent, ], - // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ SettingsAppUpdateFacade, SettingsBackupFacade, diff --git a/apps/web/src/environments/change-detection.providers.ts b/apps/web/src/environments/change-detection.providers.ts new file mode 100644 index 000000000..d0cf2caa0 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.ts @@ -0,0 +1,12 @@ +import { + EnvironmentProviders, + provideZoneChangeDetection, +} from '@angular/core'; + +// Change detection for every build: zone.js schedules the ticks. The +// *-zoneless build configurations replace this file with +// change-detection.providers.zoneless.ts while plan item C6 measures +// zoneless change detection; see docs/architecture/zoneless-migration.md. +export const changeDetectionProviders: EnvironmentProviders[] = [ + provideZoneChangeDetection({ eventCoalescing: true }), +]; diff --git a/apps/web/src/environments/change-detection.providers.zoneless.ts b/apps/web/src/environments/change-detection.providers.zoneless.ts new file mode 100644 index 000000000..30e71ff06 --- /dev/null +++ b/apps/web/src/environments/change-detection.providers.zoneless.ts @@ -0,0 +1,11 @@ +import { + EnvironmentProviders, + provideZonelessChangeDetection, +} from '@angular/core'; + +// Swapped in for change-detection.providers.ts by the *-zoneless build +// configurations only. zone.js stays in the polyfills until the flip, so +// Angular logs NG0914 in these builds; nothing patches through it. +export const changeDetectionProviders: EnvironmentProviders[] = [ + provideZonelessChangeDetection(), +]; diff --git a/apps/website/src/content/blog/library-organization-guide.mdx b/apps/website/src/content/blog/library-organization-guide.mdx index db4c04d76..a01bd14c6 100644 --- a/apps/website/src/content/blog/library-organization-guide.mdx +++ b/apps/website/src/content/blog/library-organization-guide.mdx @@ -1,16 +1,16 @@ --- title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles" description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data. -pubDate: 2026-09-27 +pubDate: 2026-10-06 author: 4gray heroImage: /iptvnator/blog/feature-guides/screenshots/guide-library-watched-dark.png tags: - guide -draft: true +draft: false faq: - q: Does an unavailable source mean my channels were deleted? a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels. - - q: Does library watched state delete entries automatically? + - q: Does source cleanup delete entries automatically? a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review. - q: What happens when I delete a source? a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state. @@ -111,6 +111,7 @@ permanent labels are more useful than an extra row of posters. ## Related +- [Back up your playlists before removing sources](/iptvnator/blog/playlist-backup-restore-guide/) - [Movie and series metadata with TMDB](/iptvnator/blog/tmdb-metadata-guide/) - [Alternative movie sources](/iptvnator/blog/alternative-sources-guide/) - [Library changes in 0.24](/iptvnator/blog/v0-24-release-notes/) diff --git a/apps/website/src/content/blog/player-controls-guide.mdx b/apps/website/src/content/blog/player-controls-guide.mdx index 085b2a475..aa6686561 100644 --- a/apps/website/src/content/blog/player-controls-guide.mdx +++ b/apps/website/src/content/blog/player-controls-guide.mdx @@ -1,13 +1,13 @@ --- title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player" description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player. -pubDate: 2026-09-27 +pubDate: 2026-10-06 author: 4gray heroImage: /iptvnator/blog/feature-guides/screenshots/guide-player-subtitles-dark.png tags: - guide - playback -draft: true +draft: false faq: - q: Why is there no quality menu? a: The menu appears when the stream exposes more than one video quality. A single video file or a channel with only one rendition has no alternatives for the player to select. diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index dca9795c3..8f98857af 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -695,7 +695,7 @@ external-player workflows; it is not copied from the HLS error payload into the evidence or technical details. HLS startup development logs are event-only: they do not include provider-supplied channel names or source URLs. -Shaka Player `5.2.4` errors cross a separate structured boundary before the +Shaka Player `5.2.12` errors cross a separate structured boundary before the HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert the installed Shaka version plus the public `Severity`, `Category`, and selected online-playback `Code` values used by the boundary. Evidence retains only @@ -749,7 +749,7 @@ allowlisted display name. `network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding. -mpegts.js `1.8.1` errors cross one shared structured boundary before the HTML5, +mpegts.js `1.8.2` errors cross one shared structured boundary before the HTML5, Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted contract. Evidence retains only an exact type/detail pair, terminal diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index d01940502..a9f26aa52 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -195,7 +195,10 @@ Page-level Back lives only in the workspace header's leading slot (see [Header Back](./workspace-shell.md#header-back)). A routed page, or the shell it renders in, registers it with `registerWorkspaceBack()` instead of drawing an arrow, so Back keeps one position and one look on every page and never -floats over a scroll owner. Without a registration the header falls back to +floats over a scroll owner. A page whose Back is history Back calls +`WorkspaceBackNavigationService.back()` with its parent route rather than +`Location.back()`, so Back still leads somewhere when the page opened the +session. Without a registration the header falls back to browser history while an in-app previous page exists, and shows nothing otherwise. An arrow that returns within a menu, dialog or player panel is not page navigation and stays in that surface; an error state may repeat the diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 585ec4245..011fd5760 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1513,7 +1513,7 @@ does not change the saved player preference. Clear DASH needs this routing too. engine: lazy `import('shaka-player')` on first use (the module is a separate lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation queue + generation guard against channel-switch races. The DOM-free Shaka - `5.2.4` public-error boundary lives in `libs/playback/util`; it version-locks + `5.2.12` public-error boundary lives in `libs/playback/util`; it version-locks its allowlisted severity/category/code values, emits only structured sanitized `PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events, diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 25ab024cf..3071a139f 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -19,9 +19,9 @@ live in `tools/performance/`. | J4 `search` | six-character query typed into global search | results list settled | J1 is instrumented: `renderer.initialBytes` from the built output, and the -runtime counters of the launch benchmark below. J2 and J3 are instrumented by -their own specs (below). J4 follows the plan in `.plans/` and is added in its -own thread; each thread names its journey and counter in the PR description. +runtime counters of the launch benchmark below. J2, J3 and J4 are +instrumented by their own specs (below). Each thread names its journey and +counter in the PR description. ## Running the journeys @@ -89,7 +89,14 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`: show a blank window and freeze its `ready-to-show` counter before its own document exists. Electron emits the event again for the real document's first paint because the window is still hidden, which is the moment - production sees. The gate also keeps the listener the app registers with + production sees. The app also shows its window at the main frame's + `did-finish-load` when that comes first (see + [When the window is shown](#when-the-window-is-shown)), so the gate keeps + the `did-finish-load` listeners registered before the gated load (the + app's) away from the `about:blank` load as well + (`evidence.rendererGateDidFinishLoadHeldOnBlank`, 1 per launch); Electron's + own listener that resolves `loadURL('about:blank')` is registered later + and still runs. The gate also keeps the listener the app registers with `ipcMain.handle('performance:read-counters')`, so the test can call it from the main process. - `journey-renderer-probe.ts` is registered with `addInitScript` on that @@ -168,8 +175,8 @@ closed or closed before the cutoff. J2's probe has no settle window most 20): the time after the first card, the value and, for each source the browser attributes the shift to, the node (`tag.class[data-test-id]`; a component host such as `lib-dashboard-rail` takes its first child's test id) -and its vertical move. A late shift can therefore be traced to its component -from the summary alone. +and its move (`deltaX`, `deltaY`, `deltaWidth`, `deltaHeight`). A late shift +can therefore be traced to its component from the summary alone. First local measurement (macOS, 2026-09-29, `master` with #1738): all windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and @@ -199,6 +206,12 @@ the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both counters were 0 in all 12 iterations of two runs, every window closed on `quiet` and `lateShifts` was empty. +On the runner the flicker was only visible on J1's fast path: on the slow +path the window got its first frame only after the hero had already +changed, so the settle window opened after the shifts (see +[When the window is shown](#when-the-window-is-shown)). With both fixes, +all 18 iterations of three runner runs read 0 (`stable: true`). + #### Idle window After the settle point J1 leaves the dashboard alone for @@ -292,10 +305,12 @@ registers the `performance:read-counters` IPC handler. Without the flag nothing is counted, no listener is attached and the handler does not exist; the preload never exposes the channel. SQL statements are counted only with `IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement -execution: the launch journey sets both (the flags are built in -`journey-launch-environment.ts`), while J2's launches and the M3U, refresh -and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped -statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes, +execution: the launch journey and J4, which reports +`renderer.sqlStatementsPerSearch`, set both (the flags are built in +`journey-launch-environment.ts`), while J2's and J3's launches and the M3U, +refresh and Xtream benchmarks do not set the SQL flag and keep measuring +unwrapped statements. A harness test fails if any other source sets the SQL +flag. After the renderer probe completes, `journey-main-counters.ts` calls the handler through `electronApp.evaluate` and the gate's tap. @@ -469,6 +484,76 @@ waits for the playlist migrations, the inventory read and `reconcileEpgSources`. No baseline yet: the counter is promoted only after a PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3). +### When the window is shown + +J1 on the CI runner was bimodal from the first runner measurements (#1717) +until 2026-10-01: 6 of 14 `master` runs between 2026-09-30 and 2026-10-01 +mixed two paths. On the slow path the first card came with 18 bridge +calls and 1,018 DOM mutations, about 940 ms after the load event. On the +fast path it came with 15 calls and 559 mutations, 280-500 ms after it. +The race also marked `renderer.ipcSerialDepthToFirstCard` (9 vs 6), +`renderer.cdTicksToFirstCard` (31 vs 21), `renderer.cdTicksIdle30s`, +`main.sqlStatementsBeforeReadyToShow` (119 vs 93) and +`renderer.layoutShiftScoreSettled` as `stable: false`. + +The three extra calls (`downloadsGetDefaultFolder` and two +`dbGetGlobalRecentlyAdded`, after `dbGetAllGlobalFavorites`) were not what +the card waited for. They only had time to finish before the card. What +ordered the card was when the hidden window got a frame. In every one of +the 48 iterations of those eight runs (two of them #1782's), `ready-to-show` +came within 180 ms of the load event on the fast path (usually about 15 ms), +and 4-5 ms after the first card on the slow path. The app showed its window only on +`ready-to-show`, and `main.ts` removes the splash in a +`requestAnimationFrame`, which the journey's end condition waits for. On +the slow path the dashboard had rendered and its data had arrived, but the +window was still hidden, no frame came, and the splash stayed. + +A minimal Electron 43.3.0 app under Xvfb in a Debian container reproduces +it deterministically. It has the same hidden window, splash and +`requestAnimationFrame` removal, plus a 3.5 MB module script before the +first frame. Its window got no frame for about a second after load, and the +`requestAnimationFrame` and `ready-to-show` both landed at about 1.25 s, in +5 of 5 launches. Without the large script, `ready-to-show` came at load. A +`backgroundColor` alone changed nothing. Showing the window at +`did-finish-load` made the `requestAnimationFrame` run on time in 5 of 5. +#1782's skeleton gates do not touch this ordering: its own run 36917107231 +still had one fast iteration among slow ones. + +The fix is in the app, so it applies to users and not only to the +journey. `apps/electron-backend/src/app/services/main-window-first-show.ts` +shows the window at `ready-to-show` or the main frame's `did-finish-load`, +whichever comes first. The window's `backgroundColor` is the splash colour, +so showing it before the first paint does not flash. `ready-to-show` still +fires after the early show (on the runner 10-190 ms after load), so +`main.sqlStatementsBeforeReadyToShow` keeps its meaning. + +Validation (Principle 3, the same journey on the same runner): three +dispatched runs of the fix (36928706097, 36928716010, 36928725392) and the +run of the commit that added the baselines (36930457538) took the fast path +in all 24 iterations, with 15 calls and 559 mutations each. + +| Runs | Slow iterations | `spawnToFirstCardMs.p50` | load → card | +| ----------------------------------------------------------- | --------------- | ------------------------- | ----------------------------- | +| `master` and #1782, 2026-09-30 to 10-01 (8 runs, see above) | 29 of 40 | 1,478-1,613 ms (one 760) | ~940 ms slow, 280-500 ms fast | +| this fix (4 runs) | 0 of 20 | 988, 1,139, 923, 1,205 ms | 360-515 ms | + +The eight earlier runs are `master` 36768881838, 36814964563, 36842198653, +36861129953, 36861409057 and 36915979562, and #1782's 36816552353 and +36917107231. The runner's own speed moves `spawnToDidFinishLoadMs.p50` between 430 and +710 ms from run to run, so compare load → card rather than absolute numbers. +The one fast master run (36915979562, P50 760 ms) had a fast runner and four +fast iterations. + +The fix first merged (#1788) into #1782's branch after #1782 had already +reached `master`, so it landed again on its own. Measured again on `master` +at bc5a7fcbf, which by then carried the redesigned dashboard hero (#1792): +three dispatched runs (37192092882, 37192097790, 37192103151) took the fast +path in all 18 iterations, with 15 calls and 558 mutations each, 466-528 ms +from load to the first card and `spawnToFirstCardMs.p50` 1,149, 1,122 and +1,170 ms. `master` without the fix was still bimodal then: its last six push +runs before 2738bc28a had 30 of 36 iterations on the slow path (18 calls, +1,031-1,033 mutations, about 940 ms from load to the card). + ### Summary schema ```json @@ -544,9 +629,10 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3). numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with `tools/performance/journey-baselines.json`. The summary writer checks only that every measured iteration reports the same counter names with finite -values, so a new counter needs no schema change. A J1 runtime baseline is added -once its counter is deterministic on the CI runner; the launch counters are -not yet (see [Ratchet](#ratchet)), so the summary is evidence only. +values, so a new counter needs no schema change. A runtime baseline is added +once its counter is deterministic on the CI runner; the enforced ones and the +reasons for the others are under +[Enforced journey counters](#enforced-journey-counters). J3 adds the `journeys.playback` entry with the same shape and no schema version change: `counters` and `wallClock` hold only plain numbers, and its @@ -555,6 +641,13 @@ iterations carry `evidence.media` (the video element at `playing`) and a `media` field (`null` for J1 and J2), which the probe's `schemaVersion` 1 readers ignore. +J4 adds the `journeys.search` entry, again with the same shape. Its +iterations carry `evidence.perKeystroke` (one entry per typed key, see +[J4](#j4-search-type-a-query-until-the-results-settle)), which the CI job +summary prints as a table for the first measured iteration. J4 has its own +renderer probe (`search-journey-probe.ts`, `schemaVersion` 1); the shared +probe is unchanged. + ## J2 `open-source`: open a source to a browsable list `open-source.journey.ts` reuses the J1 profile and process pattern: the @@ -635,7 +728,7 @@ strings and stream paths carry credentials and are never stored. | `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. | | `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. | | `renderer.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). | -| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. | +| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`, and `evidence.layoutShift.shifts` lists the first 20 counted shifts (`shiftCount` is the total) with their value, `hadRecentInput`, time since the click and the nodes that moved (`tag.class[data-test-id]` and their `deltaX`, `deltaY`, `deltaWidth` and `deltaHeight`, as J1's late shifts). | | `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. | | `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. | @@ -780,8 +873,168 @@ mutations come from the EPG timeline rendering about 240 programme blocks from the `get_simple_data_table` response before the first frame. Whether that response and its render land before `playing` is a race on a slower machine, so check the runner's `counterStability` before trusting the -mutation and request counts. No J3 baseline exists yet; J3 counters join the -ratchet once three runner runs agree. +mutation and request counts. On the runner `renderer.httpRequestsToPlaying` +and `renderer.layoutShiftScore` are enforced as guards; see +[Enforced journey counters](#enforced-journey-counters). + +## J4 `search`: type a query until the results settle + +`search.journey.ts` follows J3: the profile is seeded once through the "Add +playlist" dialogs (`seedLaunchJourneyProfile` with `SEARCH_JOURNEY_SEED`), +every iteration copies it, spawns a fresh process through `runLaunchJourney` +and hands the running app to `measureSearchJourney` in +`src/journeys/search-journey-app.ts`. One warm-up and five measured +iterations. Unlike J2 and J3 the launch runs with the main-process counters +(`mainCounters: true`, so `IPTVNATOR_PERF_CAPTURE` and +`IPTVNATOR_PERF_COUNT_SQL`), because SQL statements are a J4 counter; every +statement then runs through the counting hook. + +**Profile.** J1's M3U source plus an Xtream portal ("Journey search portal") +on the mock's existing `large:large` scenario: 60 categories of 200 items, +so 4,000 live channels, 4,000 movies and 4,000 series. No fixture was added +for the journey. The query is `system`, which matches 170 series titles of +that deterministic catalog: more than global search's first page of 100, so +the page is full and more results are available. Six characters take the +title FTS path of `globalSearch` +(`apps/electron-backend/src/app/database/operations/content.operations.ts`); +the M3U arm runs as well, because live content is included, but none of the +four fixture channels matches. Poster artwork points at `picsum.photos` and +is cancelled in the main process as in J3 +(`src/performance/journey-external-artwork.ts`, shared by both journeys); +the count is kept as `evidence.externalArtworkCancelled`. The mock is not +put behind the request ledger: global search reads only the local database. + +**What typing does.** The header search box +(`app-workspace-shell-header .search-field input[type="search"]`) applies +its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms, +`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On +`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent` +adopts `q` and runs `executeSearch` after its own 300 ms debounce, which +calls the `dbGlobalSearch` bridge method once with a limit of 101. + +**Start.** After J1 has ended, the test clicks the rail's **Global search** +link and focuses the header search box (not measured), installs the IPC +capture with a start sentinel, arms the probe +(`src/performance/search-journey-probe.ts`) and waits until the app has +been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an +unchanged `main.sqlStatements` total (30 s timeout, which fails the +iteration). J1's capture is detached. The test then types the query with one +`keyboard.type` call per character on a fixed schedule, 100 ms apart from +the first key (well below the 350 ms debounce, as steady typing would be), +and samples the main process just before each key. The probe's +capture-phase `keydown` listener on `window` stamps every key in the search +box before the app sees it; the first one starts the journey and sends +`cancelSourceProbe('__iptvnator-journey-search-start__')`. The record +rejects an iteration whose DOM mutations, bridge calls or SQL statements +moved between the quiet snapshot and the first key. + +**End.** "No DOM mutation for 200 ms after the last keystroke" alone would +end inside the 650 ms of debounce, before any query ran: nothing in the DOM +changes while the term waits. So after the sixth key the probe waits for +the results of the final term to be shown (the path ends with +`/workspace/search`, the URL `q` is the query, no `.loading-state` is +rendered and an `app-content-card` in the results container is visible). The +mutation batch that first meets that condition opens a 200 ms quiet window, +and every later batch restarts it. When the window elapses the journey has +settled: the settled moment is the last mutation batch, the counters stop at +the confirmation, and the probe sends +`cancelSourceProbe('__iptvnator-journey-search-end__')`. Without a settle +within 15 s of the last key the probe marks the iteration invalid +(`settle-timeout`). + +The DOM alone cannot tell the final term's results from an earlier term's +that are still shown while the final term debounces, so the main process +also checks. A listener on the renderer-API trace channel stamps every +`dbGlobalSearch` event on arrival, with the term and result length that the +preload's summaries carry. The record requires that the last query started +between the start and end sentinels is for the final term and completed +before the end sentinel (`final-query-not-run`, `final-query-incomplete`). +The final query's start shows the loading state, which keeps the quiet +window closed until its results replace the old ones. +`evidence.finalQuery` keeps its term, result length and duration. The +record also rejects an iteration in which two keydowns were more than +250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`). +At that point a late key on a busy machine could let the 350 ms debounce +apply an intermediate term, which steady typing does not. + +A settle that times out fails the iteration with what the probe and the +main process saw: the URL `q`, the input's value, the results view, the +bridge calls, renderer console errors, the SQL totals before each key, and +the traced `dbGlobalSearch` calls with their terms and result lengths. + +### Counters + +| Counter | Source | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `renderer.ipcCallsPerSearch` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. `evidence.ipcCallsByMethod` names them. | +| `renderer.sqlStatementsPerSearch` | `main.sqlStatements` (main thread and database worker, see [Main-process counters](#main-process-counters)) read in the main process when the start sentinel and the end sentinel arrive (a listener on the trace channel calls the counters handler through the gate, which reads the registry synchronously), so the count covers exactly the IPC capture's window and never work just before the first key or after the settle. The worker reports its count before the response it belongs to, so the statements of a query are counted before its results reach the renderer. The two values are `evidence.sqlAtSentinels`; statements from the end sentinel until 500 ms after the test read the summary are kept as `evidence.sqlStatementsAfterSettled`. | +| `renderer.ipcSerialDepthToResults` | `computeJourneyIpcSerialDepth` over the capture's timeline between the sentinels (see [Serial IPC depth](#serial-ipc-depth)); `evidence.ipcSerialDepth.chain` and `evidence.ipcTimeline` show the calls. | +| `renderer.domMutationsToResults` | `MutationRecord`s from the first keydown until the quiet window was confirmed (by definition none arrive inside it). | +| `renderer.cdTicksToResults` | `ApplicationRef` ticks from the first keydown (read in the capture-phase listener, before the app handles the key) until the confirmation (see [Change-detection ticks](#change-detection-ticks)). | +| `renderer.layoutShiftScore` | All `layout-shift` entries from the first keydown until the confirmation, including `hadRecentInput` ones (typing is input, as in J2 and J3), rounded to three decimals; the split is under `evidence.layoutShift`. | +| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the first keydown to the confirmation. Evidence until shown to be stable on the runner. | + +`evidence.perKeystroke` breaks the journey down by key: for each typed +character, what happened from that key until the next one (the last entry: +until the settle). `domMutations` and `cdTicks` are split at the renderer's +keydown stamps. `ipcCalls`, `queryCalls` (`dbGlobalSearch` calls) and +`sqlStatements` are differences of the main-process samples taken just +before each key, so their boundaries sit a few milliseconds before the +renderer's. A search with working debounce shows zeros for the first five +keys and one query after the last; a search that queried on every key from +the second character on would show a `dbGlobalSearch` call and its +statements in each of those entries, even when a later key superseded the +result. + +No counter is listed under `unavailable`. HTTP requests are not a J4 +counter: global search does not touch the network. + +### Wall-clock + +| Entry | Derivation | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. | +| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. | + +Both are taken in the renderer. With the current debounces both include +the 650 ms the term waits (350 ms in the shell, then 300 ms in the results +component); the first also includes the 500 ms of typing. + +### First measurement + +Local, macOS, 2026-10-04, three full `perf:journeys` runs plus repeated J4 +runs. In the runs where J4 completed (the first and third full runs), every +counter was identical in all ten measured iterations except one: +`renderer.ipcCallsPerSearch` 1 (`dbGlobalSearch`), +`renderer.sqlStatementsPerSearch` 2, `renderer.ipcSerialDepthToResults` 1, +`renderer.domMutationsToResults` 402 (100 cards on the first page), +`renderer.layoutShiftScore` 0 and `renderer.longTasks` 0. +`renderer.cdTicksToResults` read 14 in all five iterations of the third run +and 13, 18, 13, 15, 13 in the first, so check the runner's +`counterStability` before trusting it. P50/P90 +`lastKeystrokeToSettledMs` 686/692 ms and `firstKeystrokeToFirstResultMs` +1,178/1,184 ms in the third run (699/720 and 1,193/1,210 in the first). + +Per keystroke, the first five keys caused one change-detection tick each +and nothing else: no bridge call, no SQL statement and no DOM mutation. +All of the work followed the sixth key. Search therefore does not run a +query per keystroke. The debounce does dominate the wall clock: about +650 ms of the 686 ms from the last key to settled is the two stacked +debounces (350 ms in the shell, then 300 ms in the results component). The +query itself, from the bridge call to the rendered first page, takes the +remaining 30-40 ms. + +About one launch in sixty showed the empty-results view: the single +`dbGlobalSearch` call went out with the same arguments (`system`, all three +types, hidden categories included, limit 101) and the main process answered +with an empty array in about 20 ms, with no renderer error. The database +was unchanged from passing launches (the same 119 statements before the +first key, the usual 2 for the query, none afterwards), and every failure +traced was the first launch after seeding. The journey fails such an +iteration instead of measuring it, so a full run occasionally fails J4. +The cause is in the app, not the harness, and is not fixed here. No J4 +baseline exists yet; J4 counters join the ratchet once three runner runs +agree. ## `renderer.initialBytes` @@ -859,7 +1112,10 @@ that file: - a measurement below its baseline passes and prints a "tighten" hint; - a measured counter without a baseline is noted, not failed; - checking nothing fails: an empty baselines file, or `--only` naming an - entry that does not exist, cannot exit 0. + entry that does not exist, cannot exit 0; +- an optional `note` (a string) is printed with the entry's failure; journey + entries use it to mark a guard that is not validated against wall-clock + (see [Enforced journey counters](#enforced-journey-counters)). `--only /` (repeatable) restricts the check to the named baselines. A script that measures one counter writes its own summary file @@ -957,25 +1213,85 @@ Pushes to `master` and manual dispatches always run it. The job is warn-only (`c weeks (plan item B3): a regression marks the job failed without failing the workflow. Making it required is a maintainer decision. -No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27 -(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary -values, `renderer.ipcCallsToFirstCard` 16 and -`renderer.domMutationsToFirstCard` 939, but the third run marked both -`stable: false`: its warm-up and one measured iteration reached the first -card in about 750 ms with 13 bridge calls and 576 mutations, the others in -about 1,400 ms with 16 and 939. The three extra calls -(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before -or after the first card depending on that race, so neither counter is -promoted until the race is understood and the counters are deterministic. -`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical -in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from -1,401 to 1,674 ms. All four stay evidence for now. Runner counters also -differ from a Mac (12 and 571 there, the fast path without the Linux-only -`getWindowState` call), so take J1 baseline values from the runner only. -`renderer.layoutShiftScoreSettled` has no baseline either: the runner read -it as `stable: false` because the dashboard hero flicker it reported was a -race there (see [Settle window](#settle-window)). That flicker is fixed; add -the runner's number once runner runs read it as `stable` too. +After the `Run the performance journeys` step, the job runs +`check-journey-ratchet.mjs --only …` on the summary that step wrote, for the +journey entries of `journey-baselines.json` (every entry except +`renderer.initialBytes`, which the `Initial bytes ratchet` job checks). A +`performance-tools` test keeps that `--only` list equal to those entries, so +a baseline cannot be added without being enforced. The step is in the job, +not in the composite action, so the weekly tightening still measures a run +that would fail it. While the job is warn-only, a regression fails the job +and not the workflow. + +#### Enforced journey counters + +Two J1 entries are validated (Principle 3) and carry no note: +`launch/renderer.ipcCallsToFirstCard` 15 and +`launch/renderer.domMutationsToFirstCard` 558 (#1828, `evidenceRun` +37192092882). #1828 removed the launch race (the window shown at +`did-finish-load`, see [When the window is shown](#when-the-window-is-shown)): +three dispatched runs on `master` read 15 / 558 in all 18 iterations, and +load to the first card went from about 940 ms to 466-528 ms. Slow-path +summaries (18 / 1,018 or more) fail the check. + +A counter is enforced once it was identical in every measured iteration of +every recent `master` run. The other entries were identical in all 55 +measured iterations of the 11 `master` runs from 2026-10-03 08:25 to +2026-10-04 06:55 (CI runs 37109621784 to 37184230956), with `slack` 0: + +| Entry | Value | Week (69 runs since 2026-09-27) | +| -------------------------------------------- | ----- | ------------------------------------------------------------------------- | +| `launch/main.modulesRegisteredBeforeWindow` | 2 | identical | +| `launch/renderer.layoutShiftScore` | 0 | identical | +| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) | +| `open-source/main.mockHttpRequestsToSettled` | 1 | identical | +| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical | +| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run | +| `playback/renderer.httpRequestsToPlaying` | 2 | identical | +| `playback/renderer.layoutShiftScore` | 0.001 | identical | + +`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233 +in every iteration of every `master` run from 84aef83a6 (#1814, page Back +buttons moved into the header) on, so its value is 0.233 with `evidenceRun` +37372780064 (b78224376). The 0.011 that #1814 added is not explained yet; +lowering it back is a separate change. + +Being deterministic is not the same as being validated. Principle 3 of the +plan promotes a counter to a guardrail once a PR has shown that lowering it +lowered the journey's wall-clock. None of these counters has that evidence +yet: `main.modulesRegisteredBeforeWindow` waits for the deferred IPC +registration (plan item C4), the layout-shift scores measure visual +stability rather than time, and no PR has moved a J2 or J3 counter. Each +entry therefore carries +`"note": "guard only, not validated: …"`. A guard stops a regression of a +deterministic number, and the checker prints the note with a failure; it +says nothing about whether lowering that number makes the journey faster. +When a PR shows that link, it drops the note and names the evidence in +`evidencePr`. `renderer.initialBytes` predates the note and carries none; +this document records no wall-clock change for it either. + +Not enforced, with the reason: + +- J1 `renderer.ipcSerialDepthToFirstCard` (6): bimodal on `master` until + #1828 (9 or 6) and identical in its three dispatched runs; a candidate + once `master` runs agree. +- J1 `main.sqlStatementsBeforeReadyToShow`: 93 or 95 even without the launch + race, because the download and recording recovery races `ready-to-show` + (plan item A2). +- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the + week, J3 1 or 2): a long task is a task over 50 ms, so the count follows + runner speed, not work. +- Every `cdTicks` counter: the zoneless migration (plan item C6) changes + them. +- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of + recent commits. +- J3 `renderer.ipcCallsToPlaying` (4 or 5) and + `renderer.domMutationsToPlaying` (6,182, 6,183 or 6,199): not identical, + and the EPG rendering work changes the mutation count. +- J4: not measured yet. + +Runner counters differ from a Mac (the Linux-only `getWindowState` call, for +one), so take every journey baseline value from the runner only. ### Weekly tightening @@ -1000,7 +1316,11 @@ its job; its entries are then unmeasured in that run. A final job runs `check-baseline-direction.mjs` without `--allow-increase`, which both the script and the job check; - a lowered entry gets `updatedAt`, `measuredWith` and `evidenceRun` (the - workflow run URL); `evidencePr` is set to the tightening PR once it exists. + workflow run URL); `evidencePr` is set to the tightening PR once it exists; + every other field, including a `note`, is kept, so a guard stays marked as + not validated after it is lowered; +- a counter already at 0 is never lowered; a layout-shift score is lowered + to the three-decimal value the summary reports. When the file changed and the run is on `master`, the job pushes `automation/performance-ratchet` and opens (or updates) a pull request with @@ -1019,8 +1339,10 @@ dispatches workflows that exist on the default branch, so before the first merge of a new or renamed workflow add a temporary `push` trigger for the branch and drop it before review, as #1760 did. Review the pull request like a manual tightening: if `master` moved since the measured commit, the -`Initial bytes ratchet` job on the pull request is what shows that the new -value still holds (the concurrent-merge effect above). +`Initial bytes ratchet` job (for `renderer.initialBytes`) and the +`Performance journeys` job (for the journey counters) on the pull request +are what show that the new values still hold (the concurrent-merge effect +above). ## Charset parse benchmark @@ -1067,7 +1389,13 @@ reports slow imports of non-Latin playlists. 3. Cover the extraction and the failure modes with `node --test` and register the test file in `tools/performance/project.json`. 4. Validate the counter before it becomes a guardrail: one PR must show that - lowering it moved wall-clock in the same journey. + lowering it moved wall-clock in the same journey. A counter that is + deterministic but not validated may be enforced as a guard: its baseline + entry carries a `note` saying so (see + [Enforced journey counters](#enforced-journey-counters)). +5. A journey counter's baseline is enforced only when it is also in the + `--only` list of the `Performance journeys` job in `ci.yml`; the + `performance-tools` tests fail when the two differ. ## Adding a journey @@ -1080,8 +1408,10 @@ reports slow imports of non-Latin playlists. 2. Give the journey its own probe options (`cardSelector`, `companionSelectors`, `routeFragment`, `startClick` for a click start, `media` for a media-event end such as J3's `playing`) or extend - `journey-renderer-probe.ts` when the end condition is neither. Use a state - key and sentinel ids of its own. Keep the probe self-contained: Playwright + `journey-renderer-probe.ts` when the end condition is neither. A journey + whose start or end does not fit that probe gets a probe of its own, as + J4's typed start and quiet-window end do (`search-journey-probe.ts`). Use + a state key and sentinel ids of its own. Keep the probe self-contained: Playwright serializes it with `toString()`. A click-started journey settles with `waitForJourneyClickQuiet` from `journey-click-settle.ts`. 3. Map the measurement to a `JourneyIterationRecord` in a diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index e9aa03a65..ddac1d09a 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -270,6 +270,22 @@ handler as the header Back. - Do not force both portals into the same browse/detail behavior unless the full portal detail architecture is being changed. +### Arrival and asynchronous ownership + +The shared catalog view initializes the category and consumes its Stalker +handoff together, after the facade's optional `routeReady` signal allows it. +Stalker exposes the route session's readiness, withheld from NavigationStart +until the destination playlist and section are installed. Cancelled navigation +reconciles the current route. Reused category routes also reinitialize when +their playlist changes; readiness changes alone must not close an open detail +on query-only navigation. + +Collection detail wrappers invalidate pending playlist loads before restoring +their store snapshot on destruction. Route sessions must compare the actual +store owner with the route, rather than trusting only their last initialized +playlist id. Xtream playlist reads likewise discard completions superseded by +a selection, metadata update, reset or newer read. + ## Xtream Xtream category and search details are represented by canonical routes. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 8d0520070..b0314ec78 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -393,6 +393,18 @@ Internal structure to preserve: `isCategoryResourceFailed()` / `isPaginatedContentFailed()` for explicit error handling. +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. `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: - Failed category or content requests must degrade into empty/error UI state, 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/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index c380f3ee4..4ee3ee303 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -276,7 +276,7 @@ pnpm nx build web pnpm run perf:initial-bytes # breakdown only pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline pnpm nx test performance-tools -pnpm run perf:journeys # J1 launch + J2 open-source journeys, one dist/performance/journeys//summary.json +pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys//summary.json ``` `perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the @@ -285,7 +285,12 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`). `tools/performance/journey-baselines.json`; baselines only move down. CI runs the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that target `master` and for `master` pushes (dispatch it with -`gh workflow run ci.yml --ref ` for a stacked branch). The weekly +`gh workflow run ci.yml --ref ` for a stacked branch). The +`Performance journeys` job checks the journey counters listed with `--only` +in its `Check the journey counters against the baselines` step against the +same file (warn-only); a new journey baseline +must be added to that list too, which `pnpm nx test performance-tools` +checks. The weekly `performance-ratchet.yml` workflow lowers baselines through a bot PR; validate a change to it with `gh workflow run performance-ratchet.yml --ref `, which measures but opens no PR off `master`. Dispatch needs the workflow file diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 44f4d2771..829e6e5f9 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -146,9 +146,9 @@ label (else the translated "Back"), whether Escape on the page runs it, and | Page | Registered by | Back runs | ≤640 px | | --- | --- | --- | --- | | Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle | -| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) | -| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) | -| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle | +| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's history Back; parent: the catalog section Discover lists (`vod` for movies, `series` for TV), the portal's default section for actor | (no drawer) | +| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | history Back; parent: the portal's default section | (no drawer) | +| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | history Back; parent: the first workspace view (`WorkspaceStartupPreferencesService.resolveDashboardPath()`: the dashboard, or sources when it is hidden) | beside the drawer toggle | Detail-page semantics (Escape, browse and watch) are in [Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus). @@ -167,6 +167,31 @@ advertises no Escape, because no page handles one for it. Pages that set `backAvailable=false`, such as M3U details, therefore show it too when they were reached by navigation. +**Parent fallback.** "Parent" in the table above: a registered page whose +Back is history Back calls `WorkspaceBackNavigationService.back(resolveParent)` +instead of `Location.back()`. It runs `Location.back()` while the previous +entry is an in-app one, by the same Navigation API test as the history +fallback. Without the API (older Safari and Firefox) the router's history +depth decides (`trackRouterHistoryDepth`): the document's first navigation is +depth 0, a push adds one, a replacement keeps it and a traversal restores the +depth recorded for its entry. The lazy workspace shell creates the service +after the first navigation began, so the tracker adopts the router's current +or last navigation: a first one is depth 0, a later one leaves the depth +unknown. A traversal to an entry from before a reload +leaves the depth unknown and keeps `Location.back()`, which then has a +previous entry. Otherwise the page opened +the session (a deep link, a reload or a restored view), where +`Location.back()` does nothing in Electron and leaves the app in a browser. +The service then navigates to the page's parent with `replaceUrl`, so history +Back cannot return to the page just left; with nothing in-app before it, the +parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and +returns null when the page knows no parent, which keeps `Location.back()`. +Portal pages build their parent with `workspacePortalCommands()` +(`@iptvnator/portal/shared/util`) from the route's `:id`; without a section, +the portal route's `redirectTo` picks the default section within the same +navigation, so the replacement still applies. Detail pages keep their own +return logic (`backClicked`). + When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live TV), where a disabled arrow would stay for the whole session. The cost is one @@ -390,15 +415,32 @@ The Electron window hides the native title bar on all desktop platforms (`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`): 1. macOS keeps the native traffic lights (`titleBarOverlay: true`, - `trafficLightPosition`); the renderer draws no window buttons. The lights - sit in the 56 px header band above the rail, so the macOS rail - (`.app-rail.is-macos`) starts its first link at 56 px: level with the - content area and the dashboard hero, with its hover surface clear of the - lights. App zoom scales CSS pixels but not the lights, so the rail - publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on - `resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when - zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at - default and minimum zoom on macOS. + `trafficLightPosition` from `MACOS_TRAFFIC_LIGHTS_POSITION` in + `@iptvnator/shared/interfaces`); the renderer draws no window buttons. + The lights sit in the header band (`--workspace-header-band`, 56 px) over + the rail and the header's leading padding. The macOS rail + (`.app-rail.is-macos`) starts its first link below the band: level with + the content area and the dashboard hero, with its hover surface clear of + the lights. The header's content starts 84 window pixels from the + window's left edge (60 px rail plus 24 px padding). macOS 26 ends the + lights at 76 (earlier releases at 68), which is where Back's left edge + sits at 100 % because of its 8 px pull-in. + + App zoom (see "Zoom level") scales CSS pixels but not the lights. On + macOS, `TrafficLightsClearanceDirective` on `.workspace-shell` reads the + page zoom factor (`outerWidth / innerWidth`, refreshed on `resize`) and + publishes the clearance in CSS pixels as `--traffic-lights-clear-x` (84 + window pixels) and `--traffic-lights-clear-y` (48: the lights' bottom + plus a gap). Zoomed out, the band grows to the vertical clearance, so the + lights never overlap the content area. The header's leading padding grows + to the horizontal clearance, less the rail column + (`--workspace-header-lights-inset`). At 100 % both match the default + layout. Off macOS nothing is published and the defaults apply. The phone + layout, which puts the rail in a row above the header, ignores the + inset. `window-controls.e2e.ts` ("macOS traffic lights") checks the rail + alignment, the first header control (the switcher on the first page, + which has no history fallback yet, then a detail page's Back) and the + content top at default and minimum zoom. 2. Windows and Linux use renderer-drawn window controls (`app-window-controls`, `libs/ui/components/src/lib/window-controls/`). `frame` is intentionally left untouched so native resize borders and @@ -510,15 +552,24 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455): 3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux the window is created hidden and enters fullscreen before its first paint. macOS ignores the option while the window is hidden (an NSWindow - only toggles fullscreen once it is on screen), so `ready-to-show` repeats + only toggles fullscreen once it is on screen), so the first show repeats the request with `setFullScreen(true)` right after `show()` wherever `isFullScreen()` is still false — never unconditionally, or the platforms that honoured the option would animate a second toggle. The saved bounds stay spread into the options — they are the normal bounds the window returns to, and the close handler keeps persisting - `getNormalBounds()`. `maximized` calls `maximize()` inside - `ready-to-show` right before `show()`, never earlier: `maximize()` on a - hidden window shows it, and a blank window would flash. + `getNormalBounds()`. `maximized` calls `maximize()` right before the + first `show()`, never earlier: `maximize()` on a hidden window shows it, + and a blank window would flash. That first show happens at + `ready-to-show` or the main frame's `did-finish-load`, whichever comes + first (`services/main-window-first-show.ts`): on Linux a hidden window + whose startup scripts ran before its first frame gets the next one about + a second later, so `ready-to-show` alone left the window off screen and + the splash's animation frame waiting. At `did-finish-load` the inline + splash is parsed, and the window's `backgroundColor` is the splash colour + (`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash` + in `apps/web/src/index.html`), so showing before the first paint does + not flash. 4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can sit anywhere in argv; the playlist-path extractor already skips every `-`-prefixed argument) forces `fullscreen` for that launch only and is diff --git a/docs/architecture/zoneless-migration.md b/docs/architecture/zoneless-migration.md index 84fdb1ede..265a14a6d 100644 --- a/docs/architecture/zoneless-migration.md +++ b/docs/architecture/zoneless-migration.md @@ -50,9 +50,10 @@ must be ticked here. playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`), `apps/web`. Each reports the tick counters before and after and runs the affected unit and E2E tests. -4. [ ] `provideZonelessChangeDetection()` behind a build-time - `fileReplacements` flag, off by default; all four journeys and the - Electron E2E suite run with it on. +4. [x] `provideZonelessChangeDetection()` behind a build-time + `fileReplacements` flag, off by default; the three implemented journeys + (J1-J3; J4 is still planned) and the Electron E2E suite run with it on + (see [Zoneless flag](#zoneless-flag)). 5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines (`renderer.cdTicksIdle30s` and any counter that becomes deterministic once the zone.js race is gone). @@ -63,25 +64,30 @@ must be ticked here. two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager` (or setting OnPush) once its template state is signals, signal inputs or explicitly marked. The guard spec compares the unticked entries with the -files that still contain `ChangeDetectionStrategy.Eager`. +files whose component metadata still sets +`changeDetection: ChangeDetectionStrategy.Eager` (comments do not count). +The settings sections read form values in their templates and the parent +patches the form outside their events (Discard, backup import, the EPG file +picker), so each marks itself on the form's `events` through +`markSectionForCheckOnFormEvents` (`apps/web/src/app/settings`). ### apps/web (15) -- [ ] `apps/web/src/app/app.component.ts` (idle audit root) -- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root) -- [ ] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts` -- [ ] `apps/web/src/app/settings/settings.component.ts` -- [ ] `apps/web/src/app/settings/settings-about-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts` -- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-general-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts` -- [ ] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` +- [x] `apps/web/src/app/app.component.ts` (idle audit root) +- [x] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root) +- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings.component.ts` +- [x] `apps/web/src/app/settings/settings-about-section.component.ts` +- [x] `apps/web/src/app/settings/settings-backup-section.component.ts` +- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts` +- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts` +- [x] `apps/web/src/app/settings/settings-epg-section.component.ts` +- [x] `apps/web/src/app/settings/settings-general-section.component.ts` +- [x] `apps/web/src/app/settings/settings-playback-section.component.ts` +- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts` +- [x] `apps/web/src/app/settings/settings-reset-section.component.ts` +- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts` +- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts` ### libs/ui (20 files, 21 components) @@ -97,14 +103,14 @@ files that still contain `ChangeDetectionStrategy.Eager`. - [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`) - [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts` - [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only) -- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts` -- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` -- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` -- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` -- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` -- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` -- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` -- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- [x] `libs/ui/playback/src/lib/art-player/art-player.component.ts` +- [x] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` +- [x] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts` +- [x] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts` +- [x] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts` +- [x] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts` +- [x] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- [x] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` `libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one. @@ -114,44 +120,44 @@ files that still contain `ChangeDetectionStrategy.Eager`. ### libs/workspace (7) -- [ ] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts` -- [ ] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root) -- [ ] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts` +- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root) +- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts` ### libs/playlist (14) -- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` -- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` -- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` -- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` -- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` +- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts` +- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts` +- [x] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts` +- [x] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts` +- [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts` +- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts` +- [x] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts` `libs/playlist/m3u/feature-player` (2) goes with the playback PR. ### libs/portal (9) -- [ ] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts` -- [ ] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` -- [ ] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` -- [ ] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts` -- [ ] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts` +- [x] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts` +- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts` +- [x] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- [x] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts` +- [x] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` Test-only files that set Eager are not listed; they do not ship. The guard skips every `*.spec.ts` / `*.test.ts` file with or without a suffix of one @@ -168,15 +174,16 @@ the field a signal (or a `computed`), or writes it through one. | Done | Site | What depends on the zone | Owning PR | | --- | --- | --- | --- | -| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | -| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | -| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | -| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist | -| [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | -| [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | -| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | -| [ ] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize` | apps/web | +| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback | +| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist | +| [x] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist | +| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist | +| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal | +| [x] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal | +| [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal | +| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web | +| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag | | [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless | ## Explicit zone and change-detector calls @@ -185,10 +192,10 @@ They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run` and `runOutsideAngular` just call through). Remove them in the flip PR, not before: with zone.js on they still matter. -- [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two +- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two `zone.run` calls around the window-close dialog (IPC `onWindowCloseRequested` and `beforeunload`). -- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: +- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`: `runOutsideAngular(() => setInterval(...))` for the position poll; `embedded-mpv-session-controller.position.spec.ts` asserts the call and changes with it. @@ -246,6 +253,66 @@ their events arrive over IPC. `apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses them). +## Zoneless flag + +`app.config.ts` takes its change-detection providers from +`apps/web/src/environments/change-detection.providers.ts` +(`provideZoneChangeDetection({ eventCoalescing: true })`). The +`electron-performance-zoneless` and `electron-e2e-zoneless` web +configurations are their base configuration plus one `fileReplacements` +swap to `change-detection.providers.zoneless.ts` +(`provideZonelessChangeDetection()`); a test in +`performance-build-config.spec.ts` pins that and refuses the swap in any +other configuration. zone.js stays in the polyfills, so these builds log +NG0914 in dev mode and nothing schedules through the zone. The Electron app +loads the renderer from `dist/apps/web`, so rebuilding only the web app +switches an existing Electron build: + +```bash +pnpm nx run electron-backend:build-performance # or build-e2e +pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless +cd apps/electron-backend-e2e +../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts +../../node_modules/.bin/playwright test --grep-invert packaged +``` + +Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e` +afterwards: their build dependencies restore the zone.js renderer. + +First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on +the flag branch; the same integration build measured with the flag off and +on, five iterations each): + +| Counter | flag off | flag on | +| --- | --- | --- | +| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 | +| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 | +| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) | +| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 | +| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) | +| `spawnToFirstCardMs` p50 | 3,071 | 828 | +| `clickToFirstPageMs` p50 | 82.8 | 80.4 | +| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 | + +J1 and J2 tick counts become deterministic without the zone.js one-tick +race, and the DOM mutations are unchanged, so nothing renders differently. +J3's tick count still varies with player events and its wall-clock +numbers rose locally; the machine was shared with other runs (load 26 to 58 +during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50 +earlier the same day), so judge J3 on the CI runner before the flip. + +Electron E2E suite on `electron-e2e-zoneless` (all specs except the +packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide` +failed on every run and is fixed above; `playlist-auto-refresh` passed on +`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the +known local flake (it fails as often on master). The IPC-driven paths +passed zoneless: external-player launch states and the MPV/VLC DASH +fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`, +`remote-control`, `picture-in-picture` and `stream-info`. Their state reaches +the renderer over IPC into signals and never ran in the zone. Embedded MPV +playback itself is covered only by the packaged frame-copy E2E, which these +runs left out; run it on a packaged zoneless build before the flip. + ## Measuring a PR Build `electron-performance` and run the journeys as described in diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts index 4c9613bc7..d225076ed 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts @@ -21,7 +21,7 @@ const METADATA = createPlaybackSourceMetadata({ }); describe('mpegts.js playback evidence', () => { - it('locks the accepted public contract to mpegts.js 1.8.1', () => { + it('locks the accepted public contract to mpegts.js 1.8.2', () => { expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION); expect(mpegts.ErrorTypes).toEqual({ NETWORK_ERROR: MpegTsPlaybackEngineType.Network, diff --git a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts index e09a80088..dbae7fe68 100644 --- a/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts @@ -11,7 +11,7 @@ import { MpegTsPlaybackStage, } from './mpegts-playback-evidence.model'; -export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.1'; +export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.2'; interface MpegTsPlaybackCause { readonly stage: MpegTsPlaybackStageValue; diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts index 5624dc09c..82c2ff85f 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts @@ -1,10 +1,10 @@ /** - * Public Shaka error values audited against the locked 5.2.4 runtime. + * Public Shaka error values audited against the locked 5.2.12 runtime. * * Keep the version assertion in the contract spec: a Shaka upgrade must stop * here for a new audit instead of silently accepting new error layouts. */ -export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.4'; +export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.12'; export const SHAKA_ERROR_SEVERITY = { RECOVERABLE: 1, @@ -123,7 +123,7 @@ export const SHAKA_ERROR_CODE = { MISSING_EME_SUPPORT: 6020, LOAD_INTERRUPTED: 7000, } as const; -/** Shaka 5.2.4 public NetworkingEngine request types used by diagnostics. */ +/** Shaka 5.2.12 public NetworkingEngine request types used by diagnostics. */ export const SHAKA_REQUEST_TYPE = { MANIFEST: 0, SEGMENT: 1, diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts index 2e0b61ca3..737e2b50b 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts @@ -65,7 +65,7 @@ describe('Shaka playback evidence', () => { } ); - it('matches the installed public Shaka 5.2.4 error contract', () => { + it('matches the installed public Shaka 5.2.12 error contract', () => { const installed = getInstalledShakaContract(); expect(installed.requestTypes).toEqual( expect.objectContaining(SHAKA_REQUEST_TYPE) diff --git a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts index 33a3e345d..3a924778c 100644 --- a/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts @@ -51,7 +51,7 @@ export function createShakaPlaybackEvidence( return httpStatus === undefined ? evidence : { ...evidence, httpStatus }; } -/** Public error.data request-type slots in Shaka 5.2.4; no URL inspection. */ +/** Public error.data request-type slots in Shaka 5.2.12; no URL inspection. */ function getNetworkStage(error: Partial | null | undefined) { if ( error?.category !== SHAKA_ERROR_CATEGORY.NETWORK || diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts new file mode 100644 index 000000000..38350f156 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.render.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; + +/** + * The dialog is OnPush and its Add button reads `form.valid` of the real URL + * import child. The fixture renders on its own (`autoDetectChanges`): a + * forced `detectChanges()` would hide a button that does not follow the + * child form. + */ +describe('AddPlaylistDialogComponent with the real URL form', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + AddPlaylistDialogComponent, + TranslateModule.forRoot(), + NoopAnimationsModule, + ], + providers: [ + { provide: DataService, useValue: { sendIpcEvent: jest.fn() } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: MAT_DIALOG_DATA, useValue: null }, + { + provide: RuntimeCapabilitiesService, + useValue: { isElectron: true }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(AddPlaylistDialogComponent); + fixture.autoDetectChanges(); + await settle(); + }); + + async function settle(): Promise { + await fixture.whenStable(); + // The render a form change schedules runs in the next macrotask. + await new Promise((resolve) => setTimeout(resolve)); + } + + const addButton = () => + Array.from( + ( + fixture.nativeElement as HTMLElement + ).querySelectorAll('button[mat-flat-button]') + ).find((button) => + button.textContent?.includes('HOME.URL_UPLOAD.ADD_PLAYLIST') + ); + const urlInput = () => + (fixture.nativeElement as HTMLElement).querySelector( + 'input[formcontrolname="playlistUrl"]' + ); + + it('enables Add once the user types a playlist URL', async () => { + expect(addButton()?.disabled).toBe(true); + + const input = urlInput(); + if (!input) throw new Error('Expected the URL input'); + input.value = 'https://example.com/list.m3u'; + input.dispatchEvent(new Event('input')); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); + + it('enables Add when the child form is filled without a template event', async () => { + expect(addButton()?.disabled).toBe(true); + + // As an auto-detect prefill does: a patch from outside the child. + fixture.componentInstance + .urlUpload() + ?.form.patchValue({ playlistUrl: 'https://example.com/list.m3u' }); + await settle(); + + expect(addButton()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 05090e988..88ded3ec6 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -72,7 +72,7 @@ const METHOD_BY_CANDIDATE_KIND: Record< selector: 'app-add-playlist', templateUrl: './add-playlist-dialog.component.html', styleUrl: './add-playlist-dialog.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class AddPlaylistDialogComponent { diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 1dcf80b52..0b86cb5af 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -147,7 +147,7 @@ function maskUrlQueryPasswords(url: string): string { selector: 'app-auto-import', templateUrl: './auto-import.component.html', styleUrl: './auto-import.component.scss', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatFormFieldModule, diff --git a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts index ae51aa9c2..d621b9410 100644 --- a/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts @@ -18,7 +18,7 @@ const KB = 1024; imports: [DragDropFileUploadDirective, MatIconModule, TranslatePipe], selector: 'app-file-upload', templateUrl: './file-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./file-upload.component.scss'], }) export class FileUploadComponent { diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index e373891b4..8cd65d5fd 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -75,7 +75,7 @@ interface StalkerSettledIdentity { ], selector: 'app-stalker-portal-import', templateUrl: './stalker-portal-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index 5eb4f7fd5..f767cffb7 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -17,7 +17,7 @@ import { TranslatePipe } from '@ngx-translate/core'; selector: 'app-text-import', templateUrl: './text-import.component.html', styleUrls: ['./text-import.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], }) export class TextImportComponent { diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index ce06b6834..59338b8bf 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -19,7 +19,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; @Component({ selector: 'app-url-upload', templateUrl: './url-upload.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatFormFieldModule, MatInputModule, diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 83870b92d..ca7c148f9 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -60,7 +60,7 @@ function xtreamServerUrlValidator( ], selector: 'app-xtream-code-import', templateUrl: './xtream-code-import.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts index cd5b6adb8..c5d7fcb18 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -71,7 +71,7 @@ import { TranslatePipe, ], templateUrl: './m3u-vod-detail.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./m3u-vod-detail.component.scss'], }) export class M3uVodDetailComponent { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts index d8ebfef71..72af02b4e 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.fullscreen-panel.spec.ts @@ -231,8 +231,12 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { .overrideComponent(VideoPlayerComponent, { set: { imports: [], - template: - '', + // The real template's channel-number overlay, so the + // OnPush timer test below can read the rendered state. + template: ` + @if (showChannelNumberOverlay()) { +
{{ channelNumberInput() }}
+ }`, }, }) .compileComponents(); @@ -247,6 +251,33 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { fixture.destroy(); }); + // OnPush: the overlay hides from a 2 s timer, outside any template + // event, so the signal write itself must schedule the render. The test + // never forces one after the timer: a plain-field write would leave the + // overlay in the DOM. + it('hides the channel-number overlay when its debounce fires', async () => { + jest.useFakeTimers(); + try { + const overlay = () => + (fixture.nativeElement as HTMLElement).querySelector( + '.channel-number-overlay' + ); + fixture.autoDetectChanges(); + component.handleChannelNumberInput('2'); + await jest.advanceTimersByTimeAsync(50); + expect(overlay()?.textContent).toBe('2'); + + await jest.advanceTimersByTimeAsync(2000); + + expect(overlay()).toBeNull(); + expect(storeMock.dispatch).toHaveBeenCalledWith( + setActiveChannelDispatch(nextChannel) + ); + } finally { + jest.useRealTimers(); + } + }); + describe('FULLSCREEN_CHANNEL_PANEL host', () => { it.each([VideoPlayer.MPV, VideoPlayer.VLC])( 'withholds rows that would leave forced-inline DASH for %s', @@ -260,7 +291,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => { url: 'http://localhost/next.mpd', }; player.set(externalPlayer); - component.playerSettings.player = externalPlayer; + component.playerSettings.set({ player: externalPlayer }); setActive(dashChannel); channels.set([dashChannel, sampleChannel, nextDashChannel]); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 481352fc8..274f29958 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -85,7 +85,7 @@ [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" [volume]="volume()" - [playerOverride]="playerSettings.player ?? null" + [playerOverride]="playerSettings().player ?? null" (playbackStarted)="refreshVolumeFromBus()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) @@ -124,7 +124,7 @@ [playerOverride]=" activeChannelIsDash() ? dashPlayerOverride() - : (playerSettings.player ?? null) + : (playerSettings().player ?? null) " [volume]="volume()" [timelineSegments]="catchupTimelineSegments()" @@ -255,10 +255,10 @@ } } - @if (showChannelNumberOverlay) { + @if (showChannelNumberOverlay()) {
- {{ channelNumberInput }} + {{ channelNumberInput() }}
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index e10c4f119..592f5bd55 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -231,7 +231,7 @@ function isInsideScrollableRegion( { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService }, ], templateUrl: './video-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './video-player.component.scss', }) export class VideoPlayerComponent @@ -713,9 +713,9 @@ export class VideoPlayerComponent ); /** Selected video player options */ - playerSettings: Partial = { + readonly playerSettings = signal>({ player: VideoPlayer.VideoJs, - }; + }); readonly isDesktop = this.runtime.isElectron; readonly supportsEpg = this.runtime.supportsEpg; @@ -731,8 +731,8 @@ export class VideoPlayerComponent ); /** Channel number input state */ - channelNumberInput = ''; - showChannelNumberOverlay = false; + readonly channelNumberInput = signal(''); + readonly showChannelNumberOverlay = signal(false); private channelNumberTimeout?: number; /** @@ -795,9 +795,9 @@ export class VideoPlayerComponent // React to settings changes effect(() => { - this.playerSettings = { + this.playerSettings.set({ player: this.settingsStore.player(), - }; + }); }); // Keep "now" fresh so EPG state re-evaluates over time. @@ -1191,10 +1191,10 @@ export class VideoPlayerComponent applySettings(): void { this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => { if (settings && Object.keys(settings as Settings).length > 0) { - this.playerSettings = { + this.playerSettings.set({ player: (settings as Settings).player || VideoPlayer.VideoJs, - }; + }); } }); } @@ -1472,12 +1472,14 @@ export class VideoPlayerComponent } // Add digit to current input - this.channelNumberInput += digit; - this.showChannelNumberOverlay = true; + this.channelNumberInput.update((input) => input + digit); + this.showChannelNumberOverlay.set(true); // Set timeout to switch channel after 2 seconds of no input this.channelNumberTimeout = window.setTimeout(() => { - this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10)); + this.switchToChannelByNumber( + parseInt(this.channelNumberInput(), 10) + ); this.clearChannelNumberInput(); }, 2000); } @@ -1547,8 +1549,8 @@ export class VideoPlayerComponent * Clear channel number input and hide overlay */ clearChannelNumberInput(): void { - this.channelNumberInput = ''; - this.showChannelNumberOverlay = false; + this.channelNumberInput.set(''); + this.showChannelNumberOverlay.set(false); if (this.channelNumberTimeout) { clearTimeout(this.channelNumberTimeout); this.channelNumberTimeout = undefined; @@ -1663,7 +1665,7 @@ export class VideoPlayerComponent return true; } - const player = this.playerSettings.player; + const player = this.playerSettings().player; return ( !this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv ); @@ -1685,7 +1687,7 @@ export class VideoPlayerComponent return true; } - return !this.isExternalPlayer(this.playerSettings.player); + return !this.isExternalPlayer(this.playerSettings().player); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts index 6dc6cf9a4..d5de21666 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts @@ -103,7 +103,7 @@ const SOURCE_CARDS: readonly SourceCard[] = [ './empty-state.responsive.scss', './empty-state.themes.scss', ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIcon, TranslatePipe], }) export class EmptyStateComponent { diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts new file mode 100644 index 000000000..1088ebc61 --- /dev/null +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info-epg-sources.component.spec.ts @@ -0,0 +1,126 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; +import { of, Subject } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { Playlist } from '@iptvnator/shared/interfaces'; +import { PlaylistInfoComponent } from './playlist-info.component'; +import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token'; + +/** + * The component is OnPush: the playlist is replaced after the awaited EPG + * cleanup, outside any template event, so the rendered source list has to + * follow without a zone-triggered tick. + */ +describe('PlaylistInfoComponent playlist EPG source rows', () => { + const keepUrl = 'https://playlist.example.com/keep.xml'; + const removeUrl = 'https://playlist.example.com/remove.xml'; + const playlist = { + id: 'playlist-1', + _id: 'playlist-1', + title: 'My Playlist', + count: 1, + importDate: '2026-04-01T00:00:00.000Z', + autoRefresh: false, + url: 'https://example.com/playlist.m3u', + epgUrls: [keepUrl, removeUrl], + detectedEpgUrls: [keepUrl, removeUrl], + } as Playlist & { id: string }; + + function render() { + TestBed.configureTestingModule({ + imports: [PlaylistInfoComponent, TranslateModule.forRoot()], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: playlist }, + { + provide: PlaylistsService, + useValue: { getPlaylistById: jest.fn(() => of(playlist)) }, + }, + { provide: DatabaseService, useValue: {} }, + { + provide: EpgRuntimeBridgeService, + useValue: { + supportsDataManagement: true, + supportsFilePicker: true, + pickEpgFile: jest + .fn() + .mockResolvedValue('/home/user/epg/guide.xml.gz'), + clearEpgDataForSource: jest + .fn() + .mockResolvedValue({ success: true }), + }, + }, + { provide: Store, useValue: { dispatch: jest.fn() } }, + { + provide: SettingsStore, + useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: MatDialogRef, + useValue: { beforeClosed: () => new Subject() }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsDesktopFileSave: false }, + }, + { provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(PlaylistInfoComponent); + fixture.detectChanges(); + return fixture; + } + + it('drops a removed source row once its cleanup resolves', async () => { + const fixture = render(); + const renderedSources = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll( + '.playlist-epg-source-row input[readonly]' + ), + (input) => (input as HTMLInputElement).value + ); + expect(renderedSources()).toEqual([keepUrl, removeUrl]); + + // From here the fixture renders on its own: a forced detectChanges() + // after the cleanup would hide a row update that schedules no render. + fixture.autoDetectChanges(); + await fixture.componentInstance.removePlaylistEpgSource(removeUrl); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(renderedSources()).toEqual([keepUrl]); + }); + + // The native file pick resolves after an await, with no template event. + // The main Save button and the field must follow without a forced render. + it('enables Save after a native EPG file pick without a forced render', async () => { + const fixture = render(); + fixture.autoDetectChanges(); + await fixture.whenStable(); + const host = fixture.nativeElement as HTMLElement; + const save = () => + host.querySelector('button[type="submit"]'); + const field = () => + host.querySelector( + '.playlist-epg-source-form .playlist-epg-source-row input' + ); + expect(save()?.disabled).toBe(true); + + await fixture.componentInstance.browsePlaylistEpgSourceInput(0); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(field()?.value).toBe('/home/user/epg/guide.xml.gz'); + expect(save()?.disabled).toBe(false); + }); +}); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 97192e68c..35a010ded 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -166,7 +166,7 @@ type DesktopFileSaveBridge = Pick< `, ], providers: [DatePipe], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, MatButton, @@ -300,8 +300,20 @@ export class PlaylistInfoComponent { ) as UntypedFormArray; } - /** Playlist object */ - playlist: Playlist & { id: string }; + /** + * Playlist object. Signal-backed because it is replaced after awaited + * hydration and EPG source edits, which no template event marks for an + * OnPush check. + */ + private readonly playlistState = signal( + this.playlistData + ); + get playlist(): Playlist & { id: string } { + return this.playlistState(); + } + set playlist(value: Playlist & { id: string }) { + this.playlistState.set(value); + } /** Form group with playlist details */ playlistDetails!: UntypedFormGroup; @@ -311,7 +323,6 @@ export class PlaylistInfoComponent { this.dialogRef?.beforeClosed().subscribe(() => { this.dialogClosing = true; }); - this.playlist = this.playlistData; this.createForm(); this.connectionTest = createXtreamConnectionTestState( this.playlistDetails diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html index 844252b24..f97919a39 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.html @@ -64,8 +64,8 @@ } @else if (showsAutoRefresh) { { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); @@ -149,9 +147,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); }); @@ -173,9 +169,7 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).not.toBeNull(); fixture.destroy(); @@ -195,12 +189,40 @@ describe('PlaylistItemComponent', () => { fixture.detectChanges(); expect( - (fixture.nativeElement as HTMLElement).querySelector( - '.refresh-btn' - ) + (fixture.nativeElement as HTMLElement).querySelector('.refresh-btn') ).toBeNull(); }); + // OnPush: the status arrives after an await outside any template event, + // so it must reach the view without a zone-triggered tick. + it('renders the portal status that resolves after the first render', async () => { + fixture.destroy(); + fixture = TestBed.createComponent(PlaylistItemComponent); + component = fixture.componentInstance; + component.item = { + title: 'Xtream Source', + _id: 'xtream-source', + count: 10, + importDate: Date.now().toString(), + autoRefresh: false, + serverUrl: 'https://example.com', + username: 'demo', + password: 'secret', + }; + // The fixture renders on its own: a forced detectChanges() after the + // await would hide a status that does not schedule a render. + fixture.autoDetectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + const statusDot = (fixture.nativeElement as HTMLElement).querySelector( + '.status-dot' + ); + expect(statusDot?.getAttribute('aria-label')).toBe( + 'Portal status: active' + ); + }); + it('renders cancel and progress UI for long-running playlist actions', () => { fixture.componentRef.setInput('isDeleting', true); fixture.componentRef.setInput( diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 033de62c5..8cbe29077 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -17,6 +17,7 @@ import { inject, input, output, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; @@ -39,7 +40,7 @@ import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; selector: 'app-playlist-item', templateUrl: './playlist-item.component.html', styleUrls: ['./playlist-item.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ SourceHealthIndicatorComponent, DatePipe, @@ -70,7 +71,7 @@ export class PlaylistItemComponent implements OnInit { readonly removeClicked = output(); readonly cancelBusyActionClicked = output(); - portalStatus: PortalStatus = 'unavailable'; + readonly portalStatus = signal('unavailable'); private readonly portalStatusService = inject(PortalStatusService); readonly runtime = inject(RuntimeCapabilitiesService); readonly sourceIcons = SOURCE_TYPE_ICONS; @@ -125,21 +126,22 @@ export class PlaylistItemComponent implements OnInit { private async checkPortalStatus() { if (this.item.serverUrl && this.item.username && this.item.password) { - this.portalStatus = + this.portalStatus.set( await this.portalStatusService.checkPortalStatus( this.item.serverUrl, this.item.username, this.item.password - ); + ) + ); } } getStatusClass(): string { - return this.portalStatusService.getStatusClass(this.portalStatus); + return this.portalStatusService.getStatusClass(this.portalStatus()); } getStatusIcon(): string { - return this.portalStatusService.getStatusIcon(this.portalStatus); + return this.portalStatusService.getStatusIcon(this.portalStatus()); } onPlaylistClick(): void { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts index b23f785c1..43558e4f4 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts @@ -181,7 +181,7 @@ export interface SourceCleanupDialogData extends SourceCleanupContext { } `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts index 3637c056a..4573a2b26 100644 --- a/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts +++ b/libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts @@ -29,7 +29,7 @@ import { PlaylistMeta, sourceHealthType } from '@iptvnator/shared/interfaces'; role="img" > }`, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts index f925a698c..19bc99759 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.spec.ts @@ -76,7 +76,10 @@ describe('CategoryContentViewComponent', () => { const hasMore = signal(false); const isAppending = signal(false); const appendError = signal(false); + const routeReady = signal(true); + const playlist = signal<{ id: string } | null>(null); const catalog = { + routeReady, provider: 'xtream' as 'xtream' | 'stalker', contentType: signal('vod'), selectedCategory: signal({ id: 1 }), @@ -90,7 +93,7 @@ describe('CategoryContentViewComponent', () => { contentSortMode, supportsRatingSort: true, minRating, - playlist: signal(null), + playlist, isPaginatedContentLoading, initialize: jest.fn(), setSearchQuery: jest.fn(), @@ -107,6 +110,8 @@ describe('CategoryContentViewComponent', () => { }; beforeEach(async () => { + routeReady.set(true); + playlist.set(null); window.history.replaceState({}, '', window.location.href); catalog.provider = 'xtream'; selectedItem.set(null); @@ -239,6 +244,20 @@ describe('CategoryContentViewComponent', () => { expect(catalog.setSearchQuery).toHaveBeenCalledWith('matrix'); }); + it('preserves route search after category initialization resets the store filter', () => { + let search = ''; + catalog.initialize.mockImplementation(() => { + search = ''; + }); + catalog.setSearchQuery.mockImplementation((query: string) => { + search = query; + }); + queryParamMap$.next(convertToParamMap({ q: 'matrix' })); + paramMap$.next(convertToParamMap({ categoryId: '5' })); + fixture.detectChanges(); + expect(search).toBe('matrix'); + }); + it('groups catalog sort and rating filters behind one refine menu trigger', () => { contentSortMode.set('date-desc'); categoryItemCount.set(12); @@ -353,6 +372,56 @@ describe('CategoryContentViewComponent', () => { }); }); + it('waits for the destination portal before initializing and consuming a Stalker handoff', () => { + catalog.provider = 'stalker'; + routeReady.set(false); + const item = { id: '42', name: 'Destination movie' }; + window.history.replaceState( + { openStalkerItem: item }, + '', + window.location.href + ); + paramMap$.next(convertToParamMap({ categoryId: '5' })); + fixture.detectChanges(); + expect(catalog.initialize).not.toHaveBeenCalled(); + expect(catalog.selectItem).not.toHaveBeenCalled(); + expect(window.history.state.openStalkerItem).toEqual(item); + + routeReady.set(true); + fixture.detectChanges(); + expect(catalog.initialize).toHaveBeenCalledWith('5'); + expect(catalog.selectItem).toHaveBeenCalledWith(item); + expect(window.history.state.openStalkerItem).toBeUndefined(); + + routeReady.set(false); + fixture.detectChanges(); + routeReady.set(true); + fixture.detectChanges(); + expect(catalog.initialize).toHaveBeenCalledTimes(1); + }); + + it('reinitializes a reused category route when the ready playlist changes', () => { + catalog.provider = 'stalker'; + playlist.set({ id: 'a' }); + paramMap$.next(convertToParamMap({ categoryId: '5' })); + fixture.detectChanges(); + routeReady.set(false); + fixture.detectChanges(); + const item = { id: '42', name: 'Movie from B' }; + window.history.replaceState( + { openStalkerItem: item }, + '', + window.location.href + ); + playlist.set({ id: 'b' }); + fixture.detectChanges(); + expect(catalog.initialize).toHaveBeenCalledTimes(1); + routeReady.set(true); + fixture.detectChanges(); + expect(catalog.initialize).toHaveBeenCalledTimes(2); + expect(catalog.selectItem).toHaveBeenCalledWith(item); + }); + it('hands provider-only presentation to the exact Stalker item after consuming navigation state', async () => { const item = { id: '42', category_id: 'vod' }; catalog.provider = 'stalker'; diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 33f4e311b..c6cf9375c 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -10,6 +10,7 @@ import { OnDestroy, OnInit, signal, + untracked, } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { map } from 'rxjs/operators'; @@ -193,8 +194,36 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { }); private previousGridResetKey: string | null = null; private hasAttemptedScrollRestore = false; + private readonly routeCategory = signal<{ id: string | null } | null>(null); + private initializedRoute: { + category: { id: string | null }; + playlistId: string | null; + } | null = null; constructor() { + effect(() => { + const category = this.routeCategory(); + if (!category || this.catalog.routeReady?.() === false) { + return; + } + const playlistId = this.catalog.playlist()?.id ?? null; + // Query-only navigation toggles readiness too; only a new + // category arrival or playlist should reset the open detail. + if ( + this.initializedRoute?.category === category && + this.initializedRoute.playlistId === playlistId + ) { + return; + } + this.initializedRoute = { category, playlistId }; + untracked(() => { + this.providerOnlyStalkerItemId.set(null); + this.catalog.initialize(category.id); + this.catalog.setSearchQuery?.(this.searchTerm()); + this.openStalkerItemFromNavigationState(); + }); + }); + effect(() => { const resetKey = this.gridResetKey(); if ( @@ -253,9 +282,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { this.activatedRoute.paramMap .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((params) => { - this.providerOnlyStalkerItemId.set(null); - this.catalog.initialize(params.get('categoryId')); - this.openStalkerItemFromNavigationState(); + this.routeCategory.set({ id: params.get('categoryId') }); }); this.activatedRoute.queryParamMap diff --git a/libs/portal/shared/data-access/src/lib/router-history-depth.ts b/libs/portal/shared/data-access/src/lib/router-history-depth.ts new file mode 100644 index 000000000..a41a4786d --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/router-history-depth.ts @@ -0,0 +1,84 @@ +import type { DestroyRef } from '@angular/core'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + type Router, +} from '@angular/router'; + +/** + * In-app history depth from router events, for runtimes without the + * Navigation API (older Safari and Firefox), where the browser does not say + * whether the previous entry belongs to this app session. + * + * The document's first navigation is depth 0, a push adds one, a replacement + * or a navigation that skips the location keeps the depth, and a traversal + * restores the depth recorded for the entry it returns to. Entries from + * before a reload were recorded by another document, so a traversal to one + * leaves the depth unknown (null). + */ +export function trackRouterHistoryDepth( + router: Pick< + Router, + 'events' | 'currentNavigation' | 'lastSuccessfulNavigation' + >, + destroyRef: Pick +): () => number | null { + let depth: number | null = null; + let pending: number | null = null; + let started = false; + const depthByNavigationId = new Map(); + // A Router without events (a partial test double) leaves the depth + // unknown, which keeps browser history Back. + if (!router.events) return () => null; + + // The tracker can start after the first navigation began: the workspace + // shell that creates it is lazy. Adopt the router's state for the events + // it missed. Only a first navigation has a known depth (0); a later one + // leaves it unknown, which keeps browser history Back. + const inFlight = router.currentNavigation?.() ?? null; + const last = router.lastSuccessfulNavigation?.() ?? null; + const adoptedId = inFlight?.id ?? null; + if (inFlight) { + started = true; + pending = inFlight.previousNavigation === null ? 0 : null; + } else if (last) { + started = true; + depth = last.previousNavigation === null ? 0 : null; + if (depth !== null) depthByNavigationId.set(last.id, depth); + } + + const subscription = router.events.subscribe((event) => { + if (event instanceof NavigationStart) { + // Already adopted above; its start may still be on its way. + if (event.id === adoptedId) return; + if (!started) { + pending = 0; + } else if (event.navigationTrigger === 'popstate') { + const restoredId = event.restoredState?.navigationId; + pending = + restoredId === undefined + ? null + : (depthByNavigationId.get(restoredId) ?? null); + } else { + const extras = router.currentNavigation()?.extras; + pending = + extras?.replaceUrl || extras?.skipLocationChange + ? depth + : (depth ?? 0) + 1; + } + started = true; + } else if (event instanceof NavigationEnd) { + depth = pending; + if (depth !== null) depthByNavigationId.set(event.id, depth); + } else if ( + event instanceof NavigationCancel || + event instanceof NavigationError + ) { + pending = depth; + } + }); + destroyRef.onDestroy(() => subscription.unsubscribe()); + return () => depth; +} diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts index 168642daf..2a5222dbd 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts @@ -1,6 +1,13 @@ import { Location } from '@angular/common'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { + Event as RouterEvent, + NavigationEnd, + NavigationStart, + Router, +} from '@angular/router'; +import { Subject } from 'rxjs'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; import { WORKSPACE_HISTORY_NAVIGATION, @@ -42,15 +49,65 @@ class FakeHistory extends EventTarget { describe('WorkspaceBackNavigationService', () => { const back = jest.fn(); + const navigate = jest.fn().mockResolvedValue(true); + const navigateByUrl = jest.fn().mockResolvedValue(true); + let routerEvents = new Subject(); + /** The parts of a router Navigation the depth tracker reads. */ + type NavigationLike = { + id?: number; + previousNavigation?: unknown; + extras: { replaceUrl?: boolean }; + }; + const currentNavigation = signal(null); + const lastSuccessfulNavigation = signal(null); + let navigationId = 0; + + /** A router navigation as the history depth tracker sees it. */ + function routerNavigation( + options: { + popstateTo?: number; + replaceUrl?: boolean; + } = {} + ): number { + const id = ++navigationId; + currentNavigation.set({ extras: { replaceUrl: options.replaceUrl } }); + routerEvents.next( + new NavigationStart( + id, + `/page-${id}`, + options.popstateTo === undefined ? 'imperative' : 'popstate', + options.popstateTo === undefined + ? null + : { navigationId: options.popstateTo } + ) + ); + routerEvents.next(new NavigationEnd(id, `/page-${id}`, `/page-${id}`)); + currentNavigation.set(null); + return id; + } function createService( history: FakeHistory | null = null ): WorkspaceBackNavigationService { back.mockReset(); + navigate.mockClear(); + navigateByUrl.mockClear(); + routerEvents = new Subject(); + navigationId = 0; TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ { provide: Location, useValue: { back } }, + { + provide: Router, + useValue: { + navigate, + navigateByUrl, + events: routerEvents, + currentNavigation, + lastSuccessfulNavigation, + }, + }, { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history as unknown as WorkspaceHistoryNavigation, @@ -60,6 +117,11 @@ describe('WorkspaceBackNavigationService', () => { return TestBed.inject(WorkspaceBackNavigationService); } + afterEach(() => { + currentNavigation.set(null); + lastSuccessfulNavigation.set(null); + }); + function createTarget(run = jest.fn()): WorkspaceBackTarget { return { label: signal(null), @@ -202,4 +264,213 @@ describe('WorkspaceBackNavigationService', () => { ); }); }); + + describe('back to a parent', () => { + it('goes back in history while the previous entry is in-app', () => { + const history = new FakeHistory(); + const service = createService(history); + history.push(); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + it('opens the parent in place of a page that opened the session', async () => { + const service = createService(new FakeHistory()); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + // Replacing keeps history Back from returning to the page. + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/dashboard', { + replaceUrl: true, + }); + expect(back).not.toHaveBeenCalled(); + }); + + it('opens the parent after a reload, whose old entries do not count', async () => { + const service = createService(new FakeHistory([false, true])); + + service.back(() => ['/workspace', 'xtreams', 'pl/1', 'vod']); + await Promise.resolve(); + + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'xtreams', 'pl/1', 'vod'], + { replaceUrl: true } + ); + expect(back).not.toHaveBeenCalled(); + }); + + it('waits for a parent that resolves asynchronously', async () => { + const service = createService(new FakeHistory()); + + service.back(() => Promise.resolve('/workspace/sources')); + await Promise.resolve(); + await Promise.resolve(); + + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + + it('keeps browser history when the page knows no parent', async () => { + const service = createService(new FakeHistory()); + + service.back(() => null); + await Promise.resolve(); + + expect(back).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + expect(navigateByUrl).not.toHaveBeenCalled(); + }); + + describe('without the Navigation API', () => { + // The router's history depth decides there (older Safari and + // Firefox): a page that opened the session must not leave the app. + it('opens the parent of the page that opened the session', async () => { + const service = createService(null); + routerNavigation(); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith( + '/workspace/dashboard', + { replaceUrl: true } + ); + }); + + it('goes back in history after the router pushed a page', () => { + const service = createService(null); + routerNavigation(); + routerNavigation(); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + + it('treats a replacement as the same entry', async () => { + const service = createService(null); + routerNavigation(); + routerNavigation({ replaceUrl: true }); + + service.back(() => ['/workspace', 'sources']); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith( + ['/workspace', 'sources'], + { replaceUrl: true } + ); + }); + + it('opens the parent again after Back returned to the first page', async () => { + const service = createService(null); + const first = routerNavigation(); + routerNavigation(); + routerNavigation({ popstateTo: first }); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + // The lazy workspace shell creates the service after the first + // navigation started, so the tracker adopts the router's state. + it('adopts a first navigation that ended before the service existed', async () => { + lastSuccessfulNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + it('adopts a first navigation whose start fired before the service existed', async () => { + currentNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + navigationId = 1; + // Only the end reaches the tracker. + routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1')); + currentNavigation.set(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + }); + + it('adopts a first navigation still in flight and counts the next push', async () => { + currentNavigation.set({ + id: 1, + previousNavigation: null, + extras: {}, + }); + const service = createService(null); + navigationId = 1; + // Its start arrives late; the end commits depth 0. + routerEvents.next(new NavigationStart(1, '/page-1')); + routerEvents.next(new NavigationEnd(1, '/page-1', '/page-1')); + currentNavigation.set(null); + + service.back(() => '/workspace/dashboard'); + await Promise.resolve(); + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledTimes(1); + + routerNavigation(); + service.back(() => '/workspace/dashboard'); + expect(back).toHaveBeenCalledTimes(1); + }); + + it('keeps browser history when created after several navigations', () => { + lastSuccessfulNavigation.set({ + id: 3, + previousNavigation: { id: 2 }, + extras: {}, + }); + const service = createService(null); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + + it('keeps browser history on an entry from before a reload', () => { + const service = createService(null); + routerNavigation(); + // Restores an entry recorded by the previous document. + routerNavigation({ popstateTo: 42 }); + const parent = jest.fn(() => '/workspace/dashboard'); + + service.back(parent); + + expect(back).toHaveBeenCalledTimes(1); + expect(parent).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts index fd79fd8da..e0731344a 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts @@ -7,7 +7,9 @@ import { InjectionToken, signal, } from '@angular/core'; +import { Router } from '@angular/router'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; +import { trackRouterHistoryDepth } from './router-history-depth'; /** The parts of the browser's Navigation API the history fallback reads. */ export type WorkspaceHistoryNavigation = Pick< @@ -28,6 +30,12 @@ export const WORKSPACE_HISTORY_NAVIGATION = } ); +/** + * Where a page's Back leads when there is no in-app history: a URL or router + * commands. Null keeps browser history. + */ +export type WorkspaceBackParent = string | readonly string[] | null; + /** * True when the previous history entry belongs to this document, i.e. the * router pushed it in this app session. Entries from before a reload or from @@ -49,8 +57,14 @@ function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean { @Injectable({ providedIn: 'root' }) export class WorkspaceBackNavigationService { private readonly location = inject(Location); + private readonly router = inject(Router); + private readonly history = inject(WORKSPACE_HISTORY_NAVIGATION); private readonly targets = signal([]); private readonly canGoBackInApp = signal(false); + /** In-app history depth where the Navigation API is missing. */ + private readonly routerDepth = this.history + ? () => null + : trackRouterHistoryDepth(this.router, inject(DestroyRef)); /** * Generic Back to the previous page. It advertises no Escape (no page @@ -73,7 +87,7 @@ export class WorkspaceBackNavigationService { }); constructor() { - const history = inject(WORKSPACE_HISTORY_NAVIGATION); + const history = this.history; if (!history) return; // Fires for router pushes and replacements and for traversals, // including a guard-cancelled Back that the router rewrites. @@ -86,6 +100,29 @@ export class WorkspaceBackNavigationService { ); } + /** + * Back for a page with a parent route. Browser history while the previous + * entry is an in-app one. Otherwise the page opened the session (deep + * link, reload, restored view), where `Location.back()` would do nothing + * in Electron or leave the app in a browser: the parent replaces the + * current entry, so history Back cannot return to the page just left. + * Without the Navigation API the router's history depth decides, and an + * unknown depth (a traversal to an entry from before a reload) keeps + * browser history, which then has a previous entry. + */ + back( + resolveParent: () => WorkspaceBackParent | Promise + ): void { + const inApp = this.history + ? this.canGoBackInApp() + : (this.routerDepth() ?? 1) > 0; + if (inApp) { + this.location.back(); + return; + } + void this.openParent(resolveParent); + } + /** * Returns the release function. It removes only this target: when one * page replaces another (a loading shell by the loaded one), creation and @@ -109,4 +146,17 @@ export class WorkspaceBackNavigationService { target.run(); return true; } + + private async openParent( + resolveParent: () => WorkspaceBackParent | Promise + ): Promise { + const parent = await resolveParent(); + if (parent === null) { + this.location.back(); + } else if (typeof parent === 'string') { + await this.router.navigateByUrl(parent, { replaceUrl: true }); + } else { + await this.router.navigate([...parent], { replaceUrl: true }); + } + } } diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index 52c4e285d..ea7a5b251 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -56,7 +56,7 @@ export interface FavoriteLayoutItem { './favorites-layout.component.scss', '../../styles/portal-sidebar.scss', ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CategoryViewComponent, ContentCardComponent, diff --git a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts index cae2b8f39..161840ee5 100644 --- a/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts +++ b/libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts @@ -25,7 +25,7 @@ import { DialogService } from '@iptvnator/ui/components'; selector: 'app-playlist-error-view', templateUrl: './playlist-error-view.component.html', styleUrls: ['./playlist-error-view.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule], }) export class PlaylistErrorViewComponent { diff --git a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts index 5028d424d..30bffdd1f 100644 --- a/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts +++ b/libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts @@ -39,7 +39,7 @@ export interface SearchFilter { TranslatePipe, ], templateUrl: './search-form.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` .search-container { diff --git a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts index 2976a0cee..610770e80 100644 --- a/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts +++ b/libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts @@ -22,7 +22,7 @@ import { selector: 'app-portal-rail-links', imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive], templateUrl: './portal-rail-links.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './portal-rail-links.component.scss', }) export class PortalRailLinksComponent { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts index 52059e9aa..4ff19f141 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.spec.ts @@ -22,6 +22,7 @@ import { routeParamSignal, resolveCurrentPortalPlaylistId, resolveCurrentPortalSection, + workspacePortalCommands, } from './portal-route.utils'; describe('portal-route.utils', () => { @@ -189,6 +190,41 @@ describe('portal-route.utils', () => { ).toBe('playlist-2'); }); + it('builds commands for the portal a nested route belongs to', () => { + const actorRoute = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + { + snapshot: { + params: { id: 'playlist-1' }, + paramMap: convertToParamMap({ id: 'playlist-1' }), + }, + }, + { + snapshot: { + params: { personId: '287' }, + paramMap: convertToParamMap({ personId: '287' }), + }, + }, + ], + } as unknown as ActivatedRoute; + const outsidePortal = { + pathFromRoot: [ + { snapshot: { params: {}, paramMap: convertToParamMap({}) } }, + ], + } as unknown as ActivatedRoute; + + expect(workspacePortalCommands(actorRoute, 'xtreams')).toEqual([ + '/workspace', + 'xtreams', + 'playlist-1', + ]); + expect( + workspacePortalCommands(actorRoute, 'stalker', 'series') + ).toEqual(['/workspace', 'stalker', 'playlist-1', 'series']); + expect(workspacePortalCommands(outsidePortal, 'xtreams')).toBeNull(); + }); + it('creates normalized query-param signals', () => { TestBed.configureTestingModule({}); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts index 9a2c81235..050baee42 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts @@ -117,6 +117,27 @@ export function resolveCurrentPortalPlaylistId( return extractPortalPlaylistId(routerUrl, provider); } +/** + * Router commands for the workspace portal that `route` belongs to (the `:id` + * param of an ancestor), or for one of its sections. Without a section the + * portal's route config redirects to its default section. Null outside a + * portal route. + */ +export function workspacePortalCommands( + route: ActivatedRoute, + provider: PortalProvider, + section?: PortalRailSection +): string[] | null { + const playlistId = resolveCurrentRouteParam(route, 'id'); + if (!playlistId) { + return null; + } + + return section + ? ['/workspace', provider, playlistId, section] + : ['/workspace', provider, playlistId]; +} + function resolveCurrentRouteParam( route: ActivatedRoute, key: string diff --git a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts index d72da4e61..de5cacd3e 100644 --- a/libs/portal/shared/util/src/lib/portal-catalog-facade.ts +++ b/libs/portal/shared/util/src/lib/portal-catalog-facade.ts @@ -38,6 +38,8 @@ export interface PortalCatalogFacade< TSelectedItem = unknown, > { readonly provider: PortalCatalogProvider; + /** Arrival state may be consumed only after the destination portal is installed. */ + readonly routeReady?: Signal; readonly contentType: Signal; readonly selectedCategory: Signal; readonly paginatedContent: Signal; 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 new file mode 100644 index 000000000..ac7f7872b --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-category-ownership.spec.ts @@ -0,0 +1,210 @@ +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { TranslateService } from '@ngx-translate/core'; +import { DataService, ParentalLockService } from '@iptvnator/services'; +import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { StalkerItvCacheService } from '../../stalker-itv-cache.service'; +import { StalkerSessionService } from '../../stalker-session.service'; +import { StalkerContentType } from '../stalker-store.contracts'; +import { withStalkerContent } from './with-stalker-content.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ warn: jest.fn(), error: jest.fn() }), +})); + +const Store = signalStore( + withState({ + currentPlaylist: undefined as PlaylistMeta | undefined, + selectedContentType: 'vod' as StalkerContentType, + selectedCategoryId: null as string | null, + searchPhrase: '', + page: 0, + }), + withMethods((store) => ({ + selectPlaylist(id: string) { + patchState(store, { + currentPlaylist: { + _id: id, + portalUrl: `http://${id}.example/portal.php`, + macAddress: '00:1A:79:00:00:01', + isFullStalkerPortal: false, + } as PlaylistMeta, + }); + }, + selectType(selectedContentType: StalkerContentType) { + patchState(store, { selectedContentType }); + }, + })), + withStalkerContent() +); + +function deferred() { + let resolve!: (value: unknown) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function settle() { + for (let i = 0; i < 10; i++) { + TestBed.tick(); + await Promise.resolve(); + } +} + +describe('Stalker category ownership', () => { + let store: InstanceType; + let request: jest.Mock; + beforeEach(() => { + request = jest.fn().mockResolvedValue({ js: [] }); + TestBed.configureTestingModule({ + providers: [ + Store, + { provide: DataService, useValue: { sendIpcEvent: request } }, + { + provide: ParentalLockService, + useValue: { active: () => false, version: () => 0 }, + }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + { + provide: StalkerSessionService, + useValue: { + ensureToken: jest + .fn() + .mockResolvedValue({ token: null }), + }, + }, + { + provide: StalkerItvCacheService, + useValue: { + versionFor: () => 0, + getChannels: () => null, + isReady: () => false, + isLoading: () => false, + progressOf: () => null, + isUnsupported: () => false, + }, + }, + ], + }); + store = TestBed.inject(Store); + void store.isCategoryResourceLoading(); + }); + + 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' }], + }); + store.selectPlaylist('a'); + await settle(); + request.mockResolvedValueOnce({ + js: [{ id: '1', title: 'After reset' }], + }); + store.resetCategories(); + store.reloadCategories(); + await settle(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('After reset'); + }); + + it('ignores the abandoned request after switching away and back to the same portal', async () => { + const old = deferred(); + request.mockReturnValueOnce(old.promise); + store.selectPlaylist('a'); + await settle(); + store.selectPlaylist('b'); + await settle(); + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Fresh A' }] }); + store.selectPlaylist('a'); + await settle(); + old.resolve({ js: [{ id: '1', title: 'Obsolete A' }] }); + await settle(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('Fresh A'); + }); + + it('loads the new portal categories even when the previous portal cache is populated', async () => { + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal A' }] }); + store.selectPlaylist('a'); + await settle(); + expect(request).toHaveBeenCalledTimes(1); + expect(store.isCategoryResourceFailed()).toBeNull(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('Portal A'); + + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'Portal B' }] }); + store.selectPlaylist('b'); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).not.toContain('Portal A'); + await settle(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('Portal B'); + expect(request).toHaveBeenCalledTimes(2); + }); + + it.each(['success', 'failure'] as const)( + 'ignores a late category %s from the previous portal', + async (outcome) => { + const old = deferred(); + request.mockReturnValueOnce(old.promise); + store.selectPlaylist('a'); + await settle(); + request.mockResolvedValueOnce({ + js: [{ id: '1', title: 'Portal B' }], + }); + store.selectPlaylist('b'); + await settle(); + if (outcome === 'success') + old.resolve({ js: [{ id: '1', title: 'Portal A' }] }); + else old.reject(new Error('Old portal offline')); + await settle(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('Portal B'); + expect(store.isCategoryResourceFailed()).toBeNull(); + } + ); + + it('does not reuse another section cache from the previous portal', async () => { + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A movies' }] }); + store.selectPlaylist('a'); + await settle(); + store.selectType('series'); + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'A series' }] }); + await settle(); + store.selectPlaylist('b'); + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B series' }] }); + await settle(); + store.selectType('vod'); + request.mockResolvedValueOnce({ js: [{ id: '1', title: 'B movies' }] }); + await settle(); + expect( + store.getCategoryResource().map((c) => c.category_name) + ).toContain('B movies'); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts index ac1a4e27d..9b1c85f17 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.spec.ts @@ -204,11 +204,11 @@ describe('withStalkerContent failure states', () => { : pending.promise ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'Five' }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); patchState(store, { searchPhrase: 'sidebar' }); await flushResources(); const orderedCalls = () => @@ -305,6 +305,7 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', @@ -312,7 +313,6 @@ describe('withStalkerContent failure states', () => { }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition( @@ -361,6 +361,7 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', @@ -368,7 +369,6 @@ describe('withStalkerContent failure states', () => { }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.itvChannels().length === 1); @@ -424,11 +424,11 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'News' }, { category_id: '9', category_name: 'Sports' }, ]); - store.setCurrentPlaylist(PLAYLIST); expect(store.itvChannelsCategory()).toBeNull(); store.setSelectedCategory('5'); @@ -460,10 +460,10 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'News' }, ]); - store.setCurrentPlaylist(PLAYLIST); store.setSelectedCategory('5'); void store.isPaginatedContentLoading(); await waitForCondition(() => store.itvChannelsCategory() === '5'); @@ -487,11 +487,11 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'News' }, { category_id: '9', category_name: 'Sports' }, ]); - store.setCurrentPlaylist(PLAYLIST); store.setSelectedCategory('5'); void store.isPaginatedContentLoading(); await waitForCondition(() => store.itvChannelsCategory() === '5'); @@ -537,11 +537,11 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 2); @@ -587,11 +587,11 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 2); @@ -634,12 +634,12 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, { category_id: '9', category_name: 'Adult' }, ]); store.setSelectedCategory('*'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 1); @@ -678,12 +678,12 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, { category_id: '9', category_name: 'Adult' }, ]); store.setSelectedCategory('*'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 2); @@ -728,12 +728,12 @@ describe('withStalkerContent failure states', () => { }) ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, { category_id: '9', category_name: 'Adult' }, ]); store.setSelectedCategory('*'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 2); @@ -774,11 +774,11 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', category_name: 'Action' }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.getPaginatedContent().length === 1); @@ -868,6 +868,7 @@ describe('withStalkerContent failure states', () => { }); store.setSelectedContentType('radio'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('radio', [ { category_id: 'radio-all', @@ -875,7 +876,6 @@ describe('withStalkerContent failure states', () => { }, ]); store.setSelectedCategory('radio-all'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.radioChannels().length === 1); @@ -915,6 +915,7 @@ describe('withStalkerContent failure states', () => { ); store.setSelectedContentType('vod'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('vod', [ { category_id: '5', @@ -922,7 +923,6 @@ describe('withStalkerContent failure states', () => { }, ]); store.setSelectedCategory('5'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => pendingByPage.get(1)?.length === 1); @@ -1010,12 +1010,12 @@ describe('withStalkerContent full ITV channel list cache', () => { function enterItvCategory(categoryId: string) { store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'News' }, { category_id: '9', category_name: 'Sports' }, ]); store.setSelectedCategory(categoryId); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); } @@ -1073,12 +1073,12 @@ describe('withStalkerContent full ITV channel list cache', () => { // Genre '19' has no channels in the cached full list. store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '5', category_name: 'News' }, { category_id: '19', category_name: 'For adults', censored: true }, ]); store.setSelectedCategory('19'); - store.setCurrentPlaylist(PLAYLIST); void store.isPaginatedContentLoading(); await waitForCondition(() => store.itvChannels().length === 1); @@ -1115,12 +1115,12 @@ describe('withStalkerContent full ITV channel list cache', () => { it('omits genres without cached channels from the count map (adult genres)', () => { setup(CACHED_CHANNELS); store.setSelectedContentType('itv'); + store.setCurrentPlaylist(PLAYLIST); store.setCategories('itv', [ { category_id: '*', category_name: 'All' }, { category_id: '5', category_name: 'News' }, { category_id: '19', category_name: 'For adults', censored: true }, ]); - store.setCurrentPlaylist(PLAYLIST); const counts = store.itvCategoryItemCounts(); expect(counts.get(5)).toBe(2); 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 ea8dd602e..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 @@ -64,6 +64,7 @@ function withheldStalkerCategoryIds( * Content/categories/channels feature state. */ export interface StalkerContentState { + categoryPlaylistKey: string | null; totalCount: number; vodCategories: StalkerCategoryItem[]; seriesCategories: StalkerCategoryItem[]; @@ -96,6 +97,7 @@ export interface StalkerContentState { } const initialContentState: StalkerContentState = { + categoryPlaylistKey: null, totalCount: 0, vodCategories: [], seriesCategories: [], @@ -134,9 +136,13 @@ interface StalkerContentResourceStoreContract extends StalkerContentFeatureStore } function getCategoriesByType( - store: StalkerCategorySliceContract, - contentType: StalkerContentType + store: StalkerCategorySliceContract & { + categoryPlaylistKey(): string | null; + }, + contentType: StalkerContentType, + playlistKey: string | null ): StalkerCategoryItem[] { + if (store.categoryPlaylistKey() !== playlistKey) return []; switch (contentType) { case 'vod': return store.vodCategories(); @@ -304,7 +310,26 @@ export function withStalkerContent() { }), loader: async ({ params, + abortSignal, }): Promise => { + const playlistKey = stalkerPlaylistKey( + params.currentPlaylist + ); + if (store.categoryPlaylistKey() !== playlistKey) { + patchState(store, { + categoryPlaylistKey: playlistKey, + vodCategories: [], + seriesCategories: [], + itvCategories: [], + radioCategories: [], + categoryError: null, + }); + } + const isCurrent = () => + !abortSignal.aborted && + stalkerPlaylistKey( + storeContext.currentPlaylist() + ) === playlistKey; if (!params.currentPlaylist) { patchState(store, { categoryError: null }); return []; @@ -312,7 +337,8 @@ export function withStalkerContent() { const cachedCategories = getCategoriesByType( store, - params.contentType + params.contentType, + playlistKey ); if (cachedCategories.length > 0) { patchState(store, { categoryError: null }); @@ -332,6 +358,7 @@ export function withStalkerContent() { } ); + if (!isCurrent()) return []; if (!Array.isArray(response?.js)) { const invalidResponseError = new Error( 'Invalid categories response' @@ -391,6 +418,7 @@ export function withStalkerContent() { return categories; } catch (error) { + if (!isCurrent()) return []; logger.warn('Error loading categories', { contentType: params.contentType, error, @@ -442,7 +470,10 @@ export function withStalkerContent() { : 0, availableCategoryCount: getCategoriesByType( store, - storeContext.selectedContentType() + storeContext.selectedContentType(), + stalkerPlaylistKey( + storeContext.currentPlaylist() + ) ).filter( (category) => String(category.category_id) !== '*' @@ -945,7 +976,11 @@ export function withStalkerContent() { storeContext.currentPlaylist(), contentType ); - const categories = getCategoriesByType(store, contentType); + const categories = getCategoriesByType( + store, + contentType, + stalkerPlaylistKey(storeContext.currentPlaylist()) + ); return withheld.size === 0 ? categories : categories.filter( @@ -1070,7 +1105,11 @@ export function withStalkerContent() { } const contentType = storeContext.selectedContentType(); - const categories = getCategoriesByType(store, contentType); + const categories = getCategoriesByType( + store, + contentType, + stalkerPlaylistKey(storeContext.currentPlaylist()) + ); return ( categories.find( @@ -1093,7 +1132,8 @@ export function withStalkerContent() { const category = getCategoriesByType( store, - storeContext.selectedContentType() + storeContext.selectedContentType(), + stalkerPlaylistKey(storeContext.currentPlaylist()) ).find( (item) => String(item.category_id) === @@ -1112,7 +1152,8 @@ export function withStalkerContent() { getAllCategoriesForSelectedType: computed(() => getCategoriesByType( store, - storeContext.selectedContentType() + storeContext.selectedContentType(), + stalkerPlaylistKey(storeContext.currentPlaylist()) ) ), isCategoryResourceLoading: computed(() => @@ -1166,8 +1207,15 @@ export function withStalkerContent() { type: StalkerContentType, categories: StalkerCategoryItem[] ) { - patchState(store, buildCategoryPatch(type, categories)); + patchState(store, { + categoryPlaylistKey: stalkerPlaylistKey( + storeContext.currentPlaylist() + ), + ...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: [], @@ -1177,6 +1225,14 @@ export function withStalkerContent() { 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[]) { patchState(store, { itvChannels: channels, diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts index c3c2b69a2..ea3bbd477 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -20,7 +19,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -53,7 +56,7 @@ import { export class StalkerActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -139,8 +142,11 @@ export class StalkerActorRouteComponent { ); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'stalker') + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 488abdeeb..156cdf6ab 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -60,7 +60,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util'; selector: 'app-stalker-catalog-detail', imports: [StalkerSeriesViewComponent, VodDetailsComponent], templateUrl: './stalker-catalog-detail.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` :host { diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts index 9913d02a9..7a9525784 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.spec.ts @@ -5,6 +5,7 @@ import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { StalkerCatalogFacadeService } from './stalker-catalog-facade.service'; +import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service'; describe('StalkerCatalogFacadeService', () => { const playlist = { @@ -101,6 +102,10 @@ describe('StalkerCatalogFacadeService', () => { TestBed.configureTestingModule({ providers: [ StalkerCatalogFacadeService, + { + provide: StalkerWorkspaceRouteSession, + useValue: { isReady: signal(true) }, + }, { provide: StalkerStore, useValue: stalkerStoreMock, diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index ebfcc5323..aaf96dcd6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -27,6 +27,7 @@ import { resolvePortalWatchState, } from '@iptvnator/portal/shared/util'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { StalkerWorkspaceRouteSession } from './stalker-workspace-route-session.service'; @Injectable() export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< @@ -52,6 +53,7 @@ export class StalkerCatalogFacadeService implements StalkerPortalCatalogFacade< private positionsLoadGeneration = 0; readonly provider = 'stalker' as const; + readonly routeReady = inject(StalkerWorkspaceRouteSession).isReady; readonly contentType = this.stalkerStore.selectedContentType; readonly selectedCategory = this.stalkerStore.getSelectedCategory; readonly paginatedContent = computed( diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index 870b72ea2..3c394f9cb 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -281,6 +281,26 @@ describe('StalkerCollectionDetailComponent', () => { fixture?.destroy(); }); + it('does not repoint the portal after a pending collection load outlives its view', async () => { + const pending = new Subject(); + playlistsService.getPlaylistById.mockReturnValue(pending); + fixture.componentRef.setInput('item', buildCollectionItem({})); + fixture.detectChanges(); + TestBed.tick(); + expect(playlistsService.getPlaylistById).toHaveBeenCalled(); + + fixture.destroy(); + const destination = { ...playlist, _id: 'destination' }; + await stalkerStore.setCurrentPlaylist(destination); + stalkerStore.setSelectedItem({ id: 'destination-item' }); + pending.next(playlist); + pending.complete(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + + expect(currentPlaylist()).toBe(destination); + expect(selectedItem()).toEqual({ id: 'destination-item' }); + }); + it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => { fixture.componentRef.setInput( 'item', diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index d53a44d8a..68582ce94 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -258,6 +258,7 @@ export class StalkerCollectionDetailComponent implements ViewInPortalHandoff { } ngOnDestroy(): void { + ++this.initRequestId; restoreStalkerCollectionStoreState( this.stalkerStore, this.originalState diff --git a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts index b1af8a07f..e2d8ad1a7 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -17,10 +16,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -58,7 +62,7 @@ interface DiscoverItem extends DiscoverTitle { export class StalkerDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -143,8 +147,18 @@ export class StalkerDiscoverRouteComponent { ); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'stalker', + discoverCatalogSection(this.facets()) + ) + ); } /** The portal ':id' param lives on an ancestor route */ diff --git a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts index efdc2bb9d..5d7ccccc6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts @@ -27,7 +27,7 @@ type FavoriteButtonItem = StalkerSelectedVodItem & { selector: 'app-favorites-button', templateUrl: './stalker-favorites-button.component.html', styleUrls: ['./stalker-favorites-button.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [AsyncPipe, DetailIconButtonComponent, MatIconModule, TranslateModule], }) export class FavoritesButtonComponent { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index f38b6b715..5005aa1c1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -679,6 +679,8 @@ export class StalkerLiveStreamLayoutComponent ); } }); + // OnPush: the Map changed outside any template event. + this.cdr.markForCheck(); }); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 0c2a71fd6..ef4c207a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -8,7 +8,6 @@ import { untracked, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -36,12 +35,14 @@ import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { isWorkspaceLayoutRoute, PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { @@ -89,7 +90,7 @@ interface StalkerFilter { }) export class StalkerSearchComponent { private readonly activatedRoute = inject(ActivatedRoute); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly dataService = inject(DataService); private readonly parentalLock = inject(ParentalLockService); private readonly playlistContext = inject(PlaylistContextFacade); @@ -418,9 +419,14 @@ export class StalkerSearchComponent { this.stalkerStore.setSelectedItem(null); } - /** Leave the search page (e.g. back to the actor page that opened it) */ + /** + * Leave the search page (e.g. back to the actor page that opened it); the + * portal's default section when the page opened the session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'stalker') + ); } onVodBack(): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index a4fd531f4..2d8ab7b36 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -155,7 +155,7 @@ interface StalkerSeriesPlaybackRequestContext { TranslatePipe, SeasonContainerComponent, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, providers: [ StalkerSeriesTmdbSeasonsService, StalkerSeriesHeroPresenter, diff --git a/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts new file mode 100644 index 000000000..00043ad11 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-workspace-back.spec.ts @@ -0,0 +1,121 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { + WorkspaceBackNavigationService, + WorkspaceBackParent, +} from '@iptvnator/portal/shared/data-access'; +import { + CatalogTitleMatchService, + TmdbEnrichmentService, +} from '@iptvnator/services'; +import { StalkerActorRouteComponent } from './stalker-actor-route.component'; +import { StalkerDiscoverRouteComponent } from './stalker-discover-route.component'; +import { StalkerSearchComponent } from './stalker-search/stalker-search.component'; + +/** + * Stalker's Discover, actor and search pages lead Back to a parent route + * when they opened the session. Their routes are Stalker's own, so the + * Xtream tests do not cover this wiring: each page must hand the service + * the parent under the portal `:id` of its ancestor route. + */ +describe('Stalker workspace Back parents', () => { + const back = jest.fn(); + let queryParams: Record; + + function activatedRoute(): ActivatedRoute { + const params = { id: 'stalker-1' }; + return { + params: of(params), + queryParams: of(queryParams), + snapshot: { queryParams, params, pathFromRoot: [] }, + pathFromRoot: [], + } as unknown as ActivatedRoute; + } + + function configure(): void { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useFactory: activatedRoute }, + { provide: WorkspaceBackNavigationService, useValue: { back } }, + { + provide: TmdbEnrichmentService, + useValue: { discoverTitles: jest.fn() }, + }, + { + provide: CatalogTitleMatchService, + useValue: { isAvailable: false, matchTitles: jest.fn() }, + }, + ], + }); + } + + /** The parent the page handed to `back()` on its last call. */ + async function lastParent(): Promise { + const resolveParent = back.mock.calls.at(-1)?.[0] as + | (() => WorkspaceBackParent | Promise) + | undefined; + if (!resolveParent) throw new Error('Expected a back() call'); + return resolveParent(); + } + + beforeEach(() => { + back.mockReset(); + queryParams = {}; + configure(); + }); + + it.each([ + ['movie', 'vod'], + ['tv', 'series'], + ])('leads a %s Discover page to the %s list', async (type, section) => { + queryParams = { type, year: '1990' }; + const page = TestBed.runInInjectionContext( + () => new StalkerDiscoverRouteComponent() + ); + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + section, + ]); + }); + + it('leads an actor page to the portal root, which redirects to its default section', async () => { + const page = TestBed.runInInjectionContext( + () => new StalkerActorRouteComponent() + ); + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + ]); + }); + + it('leads the search page to the portal root', async () => { + // The search page needs the whole portal store to construct; Back + // only uses its route and the back service. + const page = Object.assign( + Object.create(StalkerSearchComponent.prototype), + { + activatedRoute: activatedRoute(), + backNavigation: { back }, + } + ) as StalkerSearchComponent; + + page.goBack(); + + expect(await lastParent()).toEqual([ + '/workspace', + 'stalker', + 'stalker-1', + ]); + }); +}); 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 92f60e416..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 @@ -1,6 +1,11 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { NavigationEnd, Router } from '@angular/router'; +import { + NavigationCancel, + NavigationEnd, + NavigationStart, + Router, +} from '@angular/router'; import { EMPTY, Observable, Subject, of } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; @@ -49,8 +54,11 @@ function getStalkerSectionFromUrl(url: string): string | null { } describe('StalkerWorkspaceRouteSession', () => { - const routerEvents = new Subject(); + const routerEvents = new Subject< + NavigationStart | NavigationEnd | NavigationCancel + >(); const activePlaylist = signal(ACTIVE_PLAYLIST); + const currentPlaylist = signal(undefined); const selectedContentType = signal<'vod' | 'itv' | 'series' | 'radio'>( 'vod' ); @@ -61,10 +69,16 @@ describe('StalkerWorkspaceRouteSession', () => { }; const stalkerStore = { + currentPlaylist, resetCategories: jest.fn(), + reloadCategories: jest.fn(), setSelectedCategory: jest.fn(), clearSelectedItem: jest.fn(), - setCurrentPlaylist: jest.fn().mockResolvedValue(undefined), + setCurrentPlaylist: jest.fn( + async (playlist: PlaylistMeta | undefined) => { + currentPlaylist.set(playlist); + } + ), setSelectedContentType: jest.fn( (type: 'vod' | 'itv' | 'series' | 'radio') => { selectedContentType.set(type); @@ -85,6 +99,7 @@ describe('StalkerWorkspaceRouteSession', () => { beforeEach(async () => { router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`; activePlaylist.set(ACTIVE_PLAYLIST); + currentPlaylist.set(undefined); selectedContentType.set('vod'); playlistContext.syncFromUrl.mockImplementation((url: string) => ({ @@ -103,6 +118,7 @@ describe('StalkerWorkspaceRouteSession', () => { })); stalkerStore.resetCategories.mockClear(); + stalkerStore.reloadCategories.mockClear(); stalkerStore.setSelectedCategory.mockClear(); stalkerStore.clearSelectedItem.mockClear(); stalkerStore.setCurrentPlaylist.mockClear(); @@ -155,6 +171,96 @@ 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(); + currentPlaylist.set(OTHER_PLAYLIST); + routerEvents.next(new NavigationEnd(1, router.url, router.url)); + await flushEffects(); + expect(currentPlaylist()?._id).toBe(PLAYLIST_ID); + expect(session.isReady()).toBe(true); + }); + + it('withholds readiness during navigation and restores it after cancellation', async () => { + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + expect(session.isReady()).toBe(true); + routerEvents.next( + new NavigationStart(1, '/workspace/stalker/stalker-2/vod') + ); + expect(session.isReady()).toBe(false); + routerEvents.next( + new NavigationCancel( + 1, + '/workspace/stalker/stalker-2/vod', + 'cancelled' + ) + ); + await flushEffects(); + expect(session.isReady()).toBe(true); + expect(currentPlaylist()?._id).toBe(PLAYLIST_ID); + }); + + it('does not install a pending playlist after navigation leaves Stalker', async () => { + const pending = new Subject(); + playlistsService.getPlaylistById.mockReturnValue(pending); + const session = TestBed.inject(StalkerWorkspaceRouteSession); + await flushEffects(); + router.url = '/workspace/global-recent'; + playlistContext.syncFromUrl.mockReturnValue({ + provider: null, + playlistId: null, + section: null, + }); + routerEvents.next(new NavigationEnd(1, router.url, router.url)); + currentPlaylist.set(OTHER_PLAYLIST); + pending.next(FULL_STALKER_PLAYLIST); + pending.complete(); + await flushEffects(); + expect(currentPlaylist()).toBe(OTHER_PLAYLIST); + expect(session.isReady()).toBe(false); + }); + it('keeps the radio route selection after playlist bootstrap', async () => { router.url = `/workspace/stalker/${PLAYLIST_ID}/radio`; 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 053cdfb22..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 @@ -7,7 +7,13 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { NavigationEnd, Router } from '@angular/router'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router, +} from '@angular/router'; import { filter, firstValueFrom } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PortalRailSection } from '@iptvnator/portal/shared/util'; @@ -27,6 +33,7 @@ export class StalkerWorkspaceRouteSession { private readonly stalkerStore = inject(StalkerStore); private currentPlaylistId: string | null = null; + private targetPlaylistId: string | null = null; private readonly currentSection = signal(null); private readonly synced = signal(false); @@ -56,17 +63,25 @@ export class StalkerWorkspaceRouteSession { this.router.events .pipe( filter( - (event): event is NavigationEnd => - event instanceof NavigationEnd + (event) => + event instanceof NavigationStart || + event instanceof NavigationEnd || + event instanceof NavigationCancel || + event instanceof NavigationError ), takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => { + .subscribe((event) => { // Synchronous, and before the async sync: this session's // subscription is registered from an ENVIRONMENT_INITIALIZER // when the route injector is created, so it runs ahead of the // components that read `isReady`. this.synced.set(false); + if (event instanceof NavigationStart) { + this.targetPlaylistId = null; + ++this.syncGeneration; + return; + } void this.syncRouteContext(); }); @@ -85,6 +100,10 @@ export class StalkerWorkspaceRouteSession { // playlist context, and deferring it onto the queue would leave every // consumer of that context a tick behind the navigation. const routeContext = this.playlistContext.syncFromUrl(this.router.url); + this.targetPlaylistId = + routeContext.provider === 'stalker' + ? routeContext.playlistId + : null; const run = async (): Promise => { try { await this.applyRouteContext(generation, routeContext); @@ -109,17 +128,42 @@ export class StalkerWorkspaceRouteSession { ? routeContext.playlistId : null; - if (playlistId && this.currentPlaylistId !== playlistId) { + if ( + !playlistId || + this.targetPlaylistId !== playlistId || + this.destroyRef.destroyed + ) { + return; + } + + if ( + 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(); const playlist = await this.resolveStalkerPlaylist(playlistId); + if ( + this.targetPlaylistId !== playlistId || + this.destroyRef.destroyed || + playlist?._id !== playlistId + ) { + return; + } 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) { + if (generation !== this.syncGeneration || this.destroyRef.destroyed) { // A newer arrival is queued behind this one and owns readiness. return; } @@ -166,7 +210,7 @@ export class StalkerWorkspaceRouteSession { const activePlaylist = this.playlistContext.activePlaylist(); if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) { - return activePlaylist; + return activePlaylist ?? undefined; } const storedPlaylist = await firstValueFrom( @@ -174,13 +218,17 @@ export class StalkerWorkspaceRouteSession { { defaultValue: null } ); - return storedPlaylist ?? activePlaylist ?? undefined; + return storedPlaylist?._id === playlistId + ? storedPlaylist + : activePlaylist?._id === playlistId + ? activePlaylist + : undefined; } private hasExplicitStalkerPortalMode( playlistId: string, playlist: PlaylistMeta | null - ): playlist is PlaylistMeta { + ): boolean { return ( playlist?._id === playlistId && playlist.isFullStalkerPortal !== undefined diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index 56db7caae..794d904a7 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -33,18 +33,30 @@ const SERVER_EPOCH = 1_788_723_000; const TestPortalStore = signalStore(withPortal()); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe('withPortal', () => { let store: InstanceType; let apiService: { getAccountInfo: jest.Mock; }; let rememberServerTimezone: jest.Mock; + let getPlaylist: jest.Mock; + let createPlaylist: jest.Mock; beforeEach(() => { apiService = { getAccountInfo: jest.fn(), }; rememberServerTimezone = jest.fn().mockResolvedValue(undefined); + getPlaylist = jest.fn(); + createPlaylist = jest.fn().mockResolvedValue(undefined); TestBed.configureTestingModule({ providers: [ @@ -56,7 +68,8 @@ describe('withPortal', () => { { provide: XTREAM_DATA_SOURCE, useValue: { - getPlaylist: jest.fn(), + getPlaylist, + createPlaylist, rememberServerTimezone, }, }, @@ -67,6 +80,103 @@ describe('withPortal', () => { store.setCurrentPlaylist(PLAYLIST); }); + describe('playlist loading', () => { + beforeEach(() => { + store.setPlaylistId(PLAYLIST.id); + }); + + it('refreshes the selected playlist from storage while its owner is unchanged', async () => { + const refreshed = { ...PLAYLIST, name: 'Renamed portal' }; + getPlaylist.mockResolvedValue(refreshed); + + await store.fetchPlaylist(); + + expect(store.currentPlaylist()).toEqual(refreshed); + }); + + it('ignores a playlist response after switching to another portal', async () => { + const answer = deferred(); + getPlaylist.mockReturnValue(answer.promise); + const pending = store.fetchPlaylist(); + const other = { ...PLAYLIST, id: 'playlist-2' }; + store.setPlaylistId(other.id); + store.setCurrentPlaylist(other); + + answer.resolve(PLAYLIST); + await pending; + + expect(store.playlistId()).toBe(other.id); + expect(store.currentPlaylist()).toEqual(other); + }); + + it.each(['setCurrentPlaylist', 'updatePlaylist'] as const)( + 'keeps same-id connection edits made with %s while a playlist load is pending', + async (updateMethod) => { + const answer = deferred(); + getPlaylist.mockReturnValue(answer.promise); + const pending = store.fetchPlaylist(); + const edited = { + ...PLAYLIST, + serverUrl: 'https://moved.example.com', + userAgent: 'New player', + }; + store[updateMethod](edited); + + answer.resolve(PLAYLIST); + await pending; + + expect(store.currentPlaylist()).toEqual(edited); + } + ); + + it('does not repopulate a reset portal from a pending playlist load', async () => { + const answer = deferred(); + getPlaylist.mockReturnValue(answer.promise); + const pending = store.fetchPlaylist(); + store.resetPortal(); + + answer.resolve(PLAYLIST); + await pending; + + expect(store.playlistId()).toBeNull(); + expect(store.currentPlaylist()).toBeNull(); + }); + + it('keeps a newer refresh when two reads of the same playlist finish out of order', async () => { + const first = deferred(); + const second = deferred(); + getPlaylist + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const firstPending = store.fetchPlaylist(); + const secondPending = store.fetchPlaylist(); + const refreshed = { ...PLAYLIST, name: 'Renamed portal' }; + second.resolve(refreshed); + await secondPending; + first.resolve(PLAYLIST); + await firstPending; + + expect(store.currentPlaylist()).toEqual(refreshed); + }); + + it('does not restore the old portal after pending playlist creation completes', async () => { + const creation = deferred(); + getPlaylist.mockResolvedValue(null); + createPlaylist.mockReturnValue(creation.promise); + const pending = store.fetchPlaylist(); + await Promise.resolve(); + expect(createPlaylist).toHaveBeenCalledWith(PLAYLIST); + const other = { ...PLAYLIST, id: 'playlist-2' }; + store.setPlaylistId(other.id); + store.setCurrentPlaylist(other); + + creation.resolve(); + await pending; + + expect(store.currentPlaylist()).toEqual(other); + }); + }); + function respondWith(serverInfo: Record | undefined) { apiService.getAccountInfo.mockResolvedValue({ user_info: { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index 69532df62..3575e496e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -54,6 +54,9 @@ export function withPortal() { withMethods((store) => { const apiService = inject(XtreamApiService); const dataSource = inject(XTREAM_DATA_SOURCE); + // Explicit selection/metadata writes and newer reads supersede + // pending storage reads, even when the playlist id is unchanged. + let playlistRequestId = 0; /** * Whether a playlist (the store's current one, or the stored @@ -108,6 +111,9 @@ export function withPortal() { * Set the current playlist ID */ setPlaylistId(playlistId: string): void { + if (store.playlistId() !== playlistId) { + playlistRequestId++; + } patchState(store, { playlistId }); }, @@ -119,10 +125,14 @@ export function withPortal() { if (!playlistId) { return; } + const requestId = ++playlistRequestId; try { const playlist = await dataSource.getPlaylist(playlistId); + if (requestId !== playlistRequestId) { + return; + } if (playlist) { patchState(store, { currentPlaylist: playlist }); @@ -138,6 +148,9 @@ export function withPortal() { } await dataSource.createPlaylist(currentPlaylist); + if (requestId !== playlistRequestId) { + return; + } patchState(store, { currentPlaylist }); } catch (error) { logger.error('Error fetching playlist', error); @@ -145,6 +158,7 @@ export function withPortal() { }, setCurrentPlaylist(playlist: XtreamPlaylistData | null): void { + playlistRequestId++; patchState(store, { currentPlaylist: playlist }); }, @@ -243,6 +257,7 @@ export function withPortal() { updatePlaylist(updates: Partial): void { const current = store.currentPlaylist(); if (current) { + playlistRequestId++; patchState(store, { currentPlaylist: { ...current, ...updates }, }); @@ -253,6 +268,7 @@ export function withPortal() { * Reset portal state */ resetPortal(): void { + playlistRequestId++; patchState(store, initialPortalState); }, }; diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts index 2d448734c..f6cdc3b33 100644 --- a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -21,7 +20,11 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { ActorViewComponent, ActorViewItem, @@ -59,7 +62,7 @@ import { export class XtreamActorRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -161,8 +164,11 @@ export class XtreamActorRouteComponent { this.openPortalSearch(item.title); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.route, 'xtreams') + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts index 6fcced29c..f1a1c18b2 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.spec.ts @@ -2,6 +2,10 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, provideRouter } from '@angular/router'; import { of } from 'rxjs'; +import { + WorkspaceBackNavigationService, + WorkspaceBackParent, +} from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { CatalogTitleMatchService, @@ -25,6 +29,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { let resolveDiscover: (titles: DiscoverTitle[] | null) => void; let discoverTitles: jest.Mock; + const back = jest.fn(); + let facetParams: Record; /** Creates the component and flushes the effect that starts the load */ function createComponent(): XtreamDiscoverRouteComponent { @@ -49,6 +55,8 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { vodStreams.set([]); serialStreams.set([]); + back.mockReset(); + facetParams = { type: 'movie', year: '1990' }; discoverTitles = jest.fn().mockImplementation( () => new Promise((resolve) => { @@ -61,13 +69,18 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { provideRouter([]), { provide: ActivatedRoute, - useValue: { - queryParams: of({ type: 'movie', year: '1990' }), + useFactory: () => ({ + queryParams: of(facetParams), snapshot: { - queryParams: { type: 'movie', year: '1990' }, + queryParams: facetParams, + params: { id: 'pl-1' }, pathFromRoot: [], }, - }, + }), + }, + { + provide: WorkspaceBackNavigationService, + useValue: { back }, }, { provide: XtreamStore, @@ -205,4 +218,26 @@ describe('XtreamDiscoverRouteComponent — catalog readiness', () => { expect(component.isLoading()).toBe(true); }); + + it.each([ + ['movie', 'vod'], + ['tv', 'series'], + ])( + 'leads a %s Discover page that opened the session to the %s list', + async (type, section) => { + facetParams = { type, year: '1990' }; + const component = createComponent(); + + component.goBack(); + const resolveParent = back.mock.calls[0][0] as () => + WorkspaceBackParent | Promise; + + expect(await resolveParent()).toEqual([ + '/workspace', + 'xtreams', + 'pl-1', + section, + ]); + } + ); }); diff --git a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts index 0399dd2bf..0e9b4d4c6 100644 --- a/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/discover/xtream-discover-route.component.ts @@ -1,4 +1,3 @@ -import { Location } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -18,10 +17,15 @@ import { pickTitleMatch, } from '@iptvnator/services'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; -import { createLatestRequestGuard } from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + createLatestRequestGuard, + workspacePortalCommands, +} from '@iptvnator/portal/shared/util'; import { DiscoverViewComponent, TitleResultsScope, + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -65,7 +69,7 @@ interface DiscoverItem extends DiscoverTitle { export class XtreamDiscoverRouteComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly xtreamStore = inject(XtreamStore); private readonly tmdbEnrichment = inject(TmdbEnrichmentService); private readonly titleMatch = inject(CatalogTitleMatchService); @@ -185,8 +189,18 @@ export class XtreamDiscoverRouteComponent { this.openPortalSearch(item.title); } + /** + * History Back; the catalog section this page lists when it opened the + * session. + */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands( + this.route, + 'xtreams', + discoverCatalogSection(this.facets()) + ) + ); } private openPortalSearch(title: string): void { diff --git a/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts index 1b0185345..72d6f17a5 100644 --- a/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts @@ -6,7 +6,7 @@ import { SearchResultsComponent } from '../search-results/search-results.compone selector: 'app-global-search-results', imports: [SearchResultsComponent], providers: [XtreamStore], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: '', }) export class GlobalSearchResultsComponent {} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts index 7afa25423..a05a42348 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.spec.ts @@ -900,6 +900,56 @@ describe('PortalChannelsListComponent', () => { ).toBe('live:290'); }); + // OnPush: favorites resolve asynchronously (IPC or IndexedDB), outside + // any template event, so the heart must follow without a zone tick. + it('renders favorites that load after the first render', async () => { + const favorites$ = new Subject(); + favoritesService.getFavorites.mockReturnValue(favorites$); + selectedTypeContentLoading.set(false); + selectedChannels.set([{ title: 'Cartoon Network', xtream_id: 50 }]); + currentPlaylist.set({ + id: 'playlist-1', + password: 'secret', + serverUrl: 'http://demo.example', + username: 'demo', + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const viewport = fixture.componentInstance.viewport(); + Object.defineProperty( + viewport?.elementRef.nativeElement, + 'clientHeight', + { configurable: true, value: 520 } + ); + viewport?.checkViewportSize(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const row = () => + fixture.debugElement.query(By.directive(ChannelListItemComponent)) + .componentInstance as ChannelListItemComponent; + expect(row().isFavorite()).toBe(false); + + // From here the fixture renders on its own: a forced detectChanges() + // would hide a favorites handler that does not mark the view. + fixture.autoDetectChanges(); + favorites$.next([ + { + content_id: 1, + playlist_id: 'playlist-1', + type: 'live', + title: 'Cartoon Network', + category_id: 7, + xtream_id: 50, + }, + ] satisfies FavoriteItem[]); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(row().isFavorite()).toBe(true); + }); + it('scrolls the virtual list to the selected live channel', () => { const channels = Array.from({ length: 20 }, (_, index) => ({ title: `Channel ${index + 1}`, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a8723a295..623fcb345 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -334,6 +334,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { true ); }); + this.cdr.markForCheck(); }); // A toggle in another list instance (the sidebar and the // fullscreen channel panel render this component side by side) @@ -771,6 +772,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { this.epgQueueService.invalidate(streamId); this.epgPrograms.delete(streamId); this.currentProgramsProgress.delete(streamId); + this.cdr.markForCheck(); this.loadEpgForVisibleChannels(this.channelsToPreview()); }); } diff --git a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts index cfa8c7e44..737eee026 100644 --- a/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts @@ -11,7 +11,6 @@ import { signal, viewChild, } from '@angular/core'; -import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -27,10 +26,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { DatabaseService } from '@iptvnator/services'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui'; +import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; import { buildXtreamNavigationTarget, isWorkspaceLayoutRoute, queryParamSignal, + workspacePortalCommands, } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { SearchFilters } from '@iptvnator/portal/xtream/data-access'; @@ -108,7 +109,7 @@ export class SearchResultsComponent implements AfterViewInit { readonly router = inject(Router); readonly activatedRoute = inject(ActivatedRoute); readonly databaseService = inject(DatabaseService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); private readonly logger = createLogger('XtreamSearchResults'); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute); readonly routeSearchTerm = queryParamSignal( @@ -538,8 +539,11 @@ export class SearchResultsComponent implements AfterViewInit { this.dialogRef?.close(); } + /** History Back; the portal's default section when it opened the session. */ goBack(): void { - this.location.back(); + this.backNavigation.back(() => + workspacePortalCommands(this.activatedRoute, 'xtreams') + ); } toggleGroupByPlaylist(value: boolean) { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index c29a4e087..c35ead6ed 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -87,7 +87,7 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a SerialDetailsMenuService, SerialDetailsDownloadAdapterService, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CastCrewRowComponent, DetailActionButtonComponent, diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 7f4520aed..69f051266 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -1,5 +1,10 @@ import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + flushMicrotasks, +} from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { VIEW_IN_PORTAL_HANDOFF } from '@iptvnator/ui/components'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; @@ -9,7 +14,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { PlaylistsService } from '@iptvnator/services'; import { Playlist } from '@iptvnator/shared/interfaces'; -import { firstValueFrom, of } from 'rxjs'; +import { Subject, firstValueFrom, of } from 'rxjs'; import { SerialDetailsComponent } from './serial-details/serial-details.component'; import { XTREAM_SERIES_RESUME_TARGET } from './serial-details/serial-details-resume-target.token'; import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component'; @@ -17,9 +22,7 @@ import { XtreamCollectionDetailComponent } from './xtream-collection-detail.comp describe('XtreamCollectionDetailComponent', () => { let fixture: ComponentFixture; let playlistId: ReturnType>; - let currentPlaylist: ReturnType< - typeof signal - >; + let currentPlaylist: ReturnType>; let selectedContentType: ReturnType< typeof signal<'live' | 'vod' | 'series'> >; @@ -66,9 +69,8 @@ describe('XtreamCollectionDetailComponent', () => { (value: 'live' | 'vod' | 'series') => selectedContentType.set(value) ), - setSelectedCategory: jest.fn( - (value: number | null) => - selectedCategoryId.set(value) + setSelectedCategory: jest.fn((value: number | null) => + selectedCategoryId.set(value) ), setSelectedItem: jest.fn((value: unknown) => selectedItem.set(value) @@ -178,6 +180,53 @@ describe('XtreamCollectionDetailComponent', () => { expect(cancelDetailsRequest).toHaveBeenCalledTimes(1); }); + it('ignores a pending playlist load after the collection detail is destroyed', fakeAsync(() => { + const pendingPlaylist = new Subject(); + jest.spyOn( + TestBed.inject(PlaylistsService), + 'getPlaylistById' + ).mockReturnValue(pendingPlaylist); + fixture.componentRef.setInput('item', { + uid: 'xtream::xtream-1::movie:99', + name: 'Movie One', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Portal', + xtreamId: 99, + categoryId: 42, + } satisfies UnifiedCollectionItem); + fixture.detectChanges(); + + fixture.destroy(); + const nextPlaylist: XtreamPlaylistData = { + id: 'xtream-2', + name: 'Next Portal', + serverUrl: 'http://next.example', + username: 'next-user', + password: 'next-pass', + type: 'xtream', + }; + playlistId.set(nextPlaylist.id); + currentPlaylist.set(nextPlaylist); + selectedContentType.set('live'); + + pendingPlaylist.next({ + _id: 'xtream-1', + title: 'Xtream Portal', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + } as Playlist); + flushMicrotasks(); + + expect(playlistId()).toBe('xtream-2'); + expect(currentPlaylist()).toBe(nextPlaylist); + expect(selectedContentType()).toBe('live'); + expect(fixture.componentInstance.detailComponent()).toBeNull(); + expect(fixture.componentInstance.detailInjector()).toBeNull(); + })); + it('provides itself as the view-in-portal handoff to the inline detail', async () => { fixture.componentRef.setInput('item', { uid: 'xtream::xtream-1::movie:99', diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts index 13680d019..5a26dfb48 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts @@ -117,6 +117,7 @@ export class XtreamCollectionDetailComponent implements ViewInPortalHandoff { } ngOnDestroy(): void { + this.initRequestId++; this.xtreamStore.cancelDetailsRequest(); this.restoreStoreState(); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 95a3a08f2..4b5fedb14 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/embedded-mpv-session.interface'; export * from './lib/embedded-mpv-support-watch.util'; export * from './lib/subtitle-style.util'; export * from './lib/zoom-level.util'; +export * from './lib/macos-traffic-lights'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/macos-traffic-lights.ts b/libs/shared/interfaces/src/lib/macos-traffic-lights.ts new file mode 100644 index 000000000..a9fcac3f9 --- /dev/null +++ b/libs/shared/interfaces/src/lib/macos-traffic-lights.ts @@ -0,0 +1,7 @@ +/** + * Where macOS draws the native window buttons (close, minimize, zoom), in + * window points from the window's top-left corner. The main process places + * them (`trafficLightPosition`); the workspace shell keeps its controls clear + * of them at every app zoom, which scales CSS pixels but not the buttons. + */ +export const MACOS_TRAFFIC_LIGHTS_POSITION = { x: 16, y: 20 } as const; diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index dfed6d379..c6ba666d3 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -6,6 +6,7 @@ import { DatePipe } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, + ChangeDetectorRef, Component, computed, DestroyRef, @@ -83,6 +84,7 @@ export class EpgGuideComponent implements OnDestroy { private readonly translate = inject(TranslateService); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly changeDetector = inject(ChangeDetectorRef); private readonly hostElement = inject>(ElementRef); readonly close = output(); @@ -185,8 +187,7 @@ export class EpgGuideComponent implements OnDestroy { activeRow: () => this.activeRowIndex(), ensureLoaded: (channels) => this.programsService.ensureLoaded(channels), setScrollLeft: (left) => this.view.scrollLeft.set(left), - afterRender: (callback) => - afterNextRender(callback, { injector: this.injector }), + afterRender: (callback) => this.afterNextGuideRender(callback), }); private readonly dialogs = new EpgGuideDialogController( @@ -237,15 +238,23 @@ export class EpgGuideComponent implements OnDestroy { this.viewportController.whenRowsRendered( viewport, this.destroyRef, - () => - afterNextRender(() => this.jumpNow(false), { - injector: this.injector, - }) + () => this.afterNextGuideRender(() => this.jumpNow(false)) ); }); }); } + /** + * Run `callback` after the next render. The callers register from CDK + * and RxJS callbacks, outside any template event: zone.js used to follow + * those with a tick, but without it registering a render hook schedules + * no render, so the guide also marks itself for one. + */ + private afterNextGuideRender(callback: () => void): void { + afterNextRender(callback, { injector: this.injector }); + this.changeDetector.markForCheck(); + } + ngOnDestroy(): void { this.view.destroy(); this.search.destroy(); diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 208b36c4f..5710777a8 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -51,7 +51,7 @@ Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000; ], providers: [WebVideoControlsAdapter], templateUrl: './art-player.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrls: ['./art-player.component.scss'], }) export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index 34469d51a..b8464d100 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -167,7 +167,7 @@ import { PlaybackHistoryConfirmation } from '../playback-history/playback-histor `, styleUrls: ['./audio-player.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ FormsModule, MatButtonModule, diff --git a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts index f9e3ab956..415a2ab28 100644 --- a/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts +++ b/libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts @@ -57,7 +57,7 @@ import { TranslateModule } from '@ngx-translate/core'; } `, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, MatCheckboxModule, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index a0384a293..924e72e07 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -71,7 +71,7 @@ const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 3e2cc0cfe..3f97a4dbe 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -20,7 +20,7 @@ import { ChannelListContainerComponent } from '@iptvnator/ui/components'; selector: 'app-sidebar', templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListContainerComponent, MatIcon, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index 6770f466e..2996a44cc 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -66,7 +66,7 @@ const debugVjsPlayer = createDevLogger('VjsPlayer'); SeriesPlaybackNavigationControlsComponent, ], providers: [WebVideoControlsAdapter], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 4d3b658e2..9e4e4f49f 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -82,7 +82,7 @@ import { createVodSimilarInPortals } from './vod-similar-in-portals.state'; selector: 'app-vod-details', templateUrl: './vod-details.component.html', styleUrls: ['../styles/detail-view.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CastCrewRowComponent, DetailActionButtonComponent, diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index a1c55b8f9..ab0ee9aff 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -91,7 +91,7 @@ import { resolveWebPlayerSharedControls } from './web-player-shared-controls'; useFactory: resolveWebPlayerSharedControls, }, ], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class WebPlayerViewComponent implements OnDestroy { diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts index a47693c57..ace2409a3 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.spec.ts @@ -1,4 +1,5 @@ import { + discoverCatalogSection, discoverFacetKey, hasDiscoverFacet, parseDiscoverParams, @@ -94,6 +95,17 @@ describe('hasDiscoverFacet', () => { }); }); +describe('discoverCatalogSection', () => { + it('maps movies to the VOD section and TV to series', () => { + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'movie' })) + ).toBe('vod'); + expect( + discoverCatalogSection(parseDiscoverParams({ type: 'tv' })) + ).toBe('series'); + }); +}); + describe('discoverFacetKey', () => { it('produces distinct keys for distinct facet sets', () => { const keys = new Set( diff --git a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts index 47ea52486..841b959c8 100644 --- a/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts +++ b/libs/ui/shared-portals/src/lib/discover-view/discover-params.ts @@ -67,6 +67,16 @@ export function hasDiscoverFacet(facets: DiscoverRouteParams): boolean { ); } +/** + * The catalog section whose titles a Discover page lists. Its Back leads + * there when the page opened the session. + */ +export function discoverCatalogSection( + facets: DiscoverRouteParams +): 'vod' | 'series' { + return facets.type === 'tv' ? 'series' : 'vod'; +} + /** * Stable identity of one facet set. Used as the staleness-guard token by * the route containers (facets change via query params on the same route diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index b0d8f3889..56833b25a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -45,7 +45,7 @@ interface WorkspaceCommandGroupSection { selector: 'app-workspace-command-palette', imports: [MatDialogModule, MatIconModule, TranslatePipe], templateUrl: './workspace-command-palette.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-command-palette.component.scss', }) export class WorkspaceCommandPaletteComponent implements AfterViewInit { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts index ca0f75f30..70f638bfb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts @@ -54,7 +54,7 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u /> `, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-context-panel.component.scss', }) export class WorkspaceCollectionContextPanelComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index a2b87f5bd..ff449972a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -82,7 +82,7 @@ interface WorkspaceCategoryLike { WorkspaceContextErrorViewComponent, ], templateUrl: './workspace-context-panel.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-context-panel.component.scss', }) export class WorkspaceContextPanelComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts index 5f57e5cee..a92669eb5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -1,18 +1,43 @@ import { Location } from '@angular/common'; import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access'; +import { + WORKSPACE_HISTORY_NAVIGATION, + WorkspaceBackNavigationService, + WorkspaceHistoryNavigation, +} from '@iptvnator/portal/shared/data-access'; +import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; +/** Navigation API state of a session that opened on the settings page. */ +const firstPageHistory = { + currentEntry: { index: 0 }, + entries: () => [{ index: 0, sameDocument: true }], + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +} as unknown as WorkspaceHistoryNavigation; + describe('WorkspaceSettingsContextPanelComponent', () => { - function setup() { + const resolveDashboardPath = jest.fn(); + + // Without a Navigation API (jsdom's default) history is unknown, so + // Back stays the browser's. + function setup(history: WorkspaceHistoryNavigation | null = null) { + resolveDashboardPath + .mockReset() + .mockResolvedValue('/workspace/dashboard'); TestBed.configureTestingModule({ imports: [WorkspaceSettingsContextPanelComponent], providers: [ provideRouter([]), + { provide: WORKSPACE_HISTORY_NAVIGATION, useValue: history }, + { + provide: WorkspaceStartupPreferencesService, + useValue: { resolveDashboardPath }, + }, { provide: TranslateService, useValue: { @@ -66,11 +91,33 @@ describe('WorkspaceSettingsContextPanelComponent', () => { backNavigation.goBack(); expect(back).toHaveBeenCalledTimes(1); + expect(resolveDashboardPath).not.toHaveBeenCalled(); fixture.destroy(); expect(backNavigation.target()).toBeNull(); }); + it('leads to the first workspace view when settings opened the session', async () => { + setup(firstPageHistory); + const back = jest + .spyOn(TestBed.inject(Location), 'back') + .mockImplementation(() => undefined); + const navigateByUrl = jest + .spyOn(TestBed.inject(Router), 'navigateByUrl') + .mockResolvedValue(true); + resolveDashboardPath.mockResolvedValue('/workspace/sources'); + + TestBed.inject(WorkspaceBackNavigationService).goBack(); + await Promise.resolve(); + await Promise.resolve(); + + // Location.back() would do nothing in Electron or leave the PWA. + expect(back).not.toHaveBeenCalled(); + expect(navigateByUrl).toHaveBeenCalledWith('/workspace/sources', { + replaceUrl: true, + }); + }); + it('shows no counts while settings search is idle', () => { const { link } = setup(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 5a93f9125..d7cacdedd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -1,17 +1,22 @@ -import { Location } from '@angular/common'; import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access'; -import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util'; +import { + registerWorkspaceBack, + WorkspaceBackNavigationService, +} from '@iptvnator/portal/shared/data-access'; +import { + WorkspaceShellContextDrawerService, + WorkspaceStartupPreferencesService, +} from '@iptvnator/workspace/shell/util'; import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context'; @Component({ selector: 'app-workspace-settings-context-panel', imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule], styleUrls: ['./workspace-settings-context-panel.component.scss'], - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, template: `

{{ 'SETTINGS.TITLE' | translate }}

@@ -56,7 +61,10 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings }) export class WorkspaceSettingsContextPanelComponent { readonly ctx = inject(SettingsContextService); - private readonly location = inject(Location); + private readonly backNavigation = inject(WorkspaceBackNavigationService); + private readonly startupPreferences = inject( + WorkspaceStartupPreferencesService + ); // Root-provided; optional keeps standalone unit tests light. Section // links are real navigations now, so the phone drawer's NavigationEnd // auto-close fires too — the explicit close just makes the drawer react @@ -72,9 +80,14 @@ export class WorkspaceSettingsContextPanelComponent { // The panel exists exactly while the settings route shows, so it // offers the page's Back in the header. On a phone the toggle for // this drawer stays beside it: the drawer holds the sections. + // Opened as the session's first page, Back leads to the first + // workspace view (the dashboard, or sources when it is hidden). registerWorkspaceBack({ phoneDrawerToggle: 'beside', - run: () => this.location.back(), + run: () => + this.backNavigation.back(() => + this.startupPreferences.resolveDashboardPath() + ), }); } diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts index 6140c36b0..07d5c036c 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts @@ -19,7 +19,7 @@ export interface WorkspaceKeyboardShortcutsDialogData { selector: 'app-workspace-keyboard-shortcuts-dialog', imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], templateUrl: './workspace-keyboard-shortcuts-dialog.component.html', - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss', }) export class WorkspaceKeyboardShortcutsDialogComponent { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 169a8561f..6ebbbdc01 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -7,8 +7,12 @@ display: flex; align-items: center; gap: 12px; - height: 56px; + // The shell's header band: 56px, taller on macOS when zoomed out. + height: 100%; padding: 0 24px; + // macOS: app zoom does not scale the traffic lights, so zoomed out the + // leading controls keep their 100 % distance from them (see the shell). + padding-inline-start: max(24px, var(--workspace-header-lights-inset, 0px)); border-bottom: 1px solid var(--mat-sys-outline-variant); background: var(--app-header-bg, var(--mat-sys-surface-container-low)); app-region: drag; @@ -318,6 +322,10 @@ @media (max-width: 780px) { .workspace-header { padding-inline: 10px; + padding-inline-start: max( + 10px, + var(--workspace-header-lights-inset, 0px) + ); gap: 10px; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html index 13e259f50..04dadb780 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.html @@ -1,10 +1,6 @@ -